git 5.0.0.beta.1 → 5.0.0.beta.2

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/copilot-instructions.md +6 -0
  3. data/.github/prompts/iteratively-address-copilot-reviews.prompt.md +188 -0
  4. data/.github/skills/extract-facade-from-base-lib/KEYWORD_ARG_REMEDIATION.md +22 -0
  5. data/.github/skills/extract-facade-from-base-lib/SKILL.md +28 -14
  6. data/.github/skills/facade-implementation/SKILL.md +14 -0
  7. data/.github/skills/facade-test-conventions/SKILL.md +14 -0
  8. data/.rubocop.yml +5 -0
  9. data/README.md +51 -11
  10. data/UPGRADING.md +141 -0
  11. data/git.gemspec +5 -0
  12. data/lib/git/branch.rb +7 -18
  13. data/lib/git/branches.rb +2 -10
  14. data/lib/git/command_line/base.rb +10 -0
  15. data/lib/git/command_line/capturing.rb +5 -3
  16. data/lib/git/command_line/streaming.rb +5 -3
  17. data/lib/git/command_line.rb +3 -3
  18. data/lib/git/commands/base.rb +7 -6
  19. data/lib/git/commands/cat_file/batch.rb +6 -1
  20. data/lib/git/commands/cat_file/raw.rb +7 -1
  21. data/lib/git/commands/config_option_syntax/get_urlmatch.rb +5 -0
  22. data/lib/git/commands/show_ref/exclude_existing.rb +1 -1
  23. data/lib/git/commands/update_ref/batch.rb +1 -1
  24. data/lib/git/commands/version.rb +5 -0
  25. data/lib/git/commands.rb +5 -7
  26. data/lib/git/config.rb +17 -0
  27. data/lib/git/config_entry_info.rb +106 -0
  28. data/lib/git/configuring.rb +665 -0
  29. data/lib/git/deprecation.rb +9 -0
  30. data/lib/git/diff.rb +4 -8
  31. data/lib/git/diff_path_status.rb +2 -13
  32. data/lib/git/diff_stats.rb +1 -9
  33. data/lib/git/execution_context/global.rb +3 -28
  34. data/lib/git/execution_context/repository.rb +30 -41
  35. data/lib/git/execution_context.rb +43 -24
  36. data/lib/git/log.rb +3 -9
  37. data/lib/git/object.rb +14 -21
  38. data/lib/git/parsers/config_entry.rb +110 -0
  39. data/lib/git/parsers/ls_remote.rb +79 -0
  40. data/lib/git/remote.rb +7 -20
  41. data/lib/git/repository/branching.rb +183 -12
  42. data/lib/git/repository/committing.rb +64 -68
  43. data/lib/git/repository/configuring.rb +208 -13
  44. data/lib/git/repository/context_helpers.rb +264 -0
  45. data/lib/git/repository/factories.rb +682 -0
  46. data/lib/git/repository/inspecting.rb +99 -0
  47. data/lib/git/repository/maintenance.rb +65 -0
  48. data/lib/git/repository/merging.rb +63 -1
  49. data/lib/git/repository/object_operations.rb +133 -35
  50. data/lib/git/repository/path_resolver.rb +1 -1
  51. data/lib/git/repository/remote_operations.rb +166 -21
  52. data/lib/git/repository/staging.rb +187 -23
  53. data/lib/git/repository/stashing.rb +39 -3
  54. data/lib/git/repository/status_operations.rb +21 -0
  55. data/lib/git/repository.rb +68 -129
  56. data/lib/git/stash.rb +2 -9
  57. data/lib/git/stashes.rb +2 -7
  58. data/lib/git/status.rb +8 -17
  59. data/lib/git/version.rb +2 -2
  60. data/lib/git/worktree.rb +2 -15
  61. data/lib/git/worktrees.rb +2 -15
  62. data/lib/git.rb +180 -77
  63. data/redesign/3_architecture_implementation.md +148 -111
  64. data/redesign/Phase 4 - Step A.md +360 -0
  65. data/redesign/beta_release.md +107 -0
  66. data/redesign/c1c2_audit.md +566 -0
  67. data/redesign/c1c2_bucket6_lib_orphans.md +626 -0
  68. data/redesign/config_design.rb +501 -0
  69. metadata +19 -5
  70. data/lib/git/base.rb +0 -1204
  71. data/lib/git/lib.rb +0 -2855
data/lib/git/lib.rb DELETED
@@ -1,2855 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'args_builder'
4
- require_relative 'commands'
5
-
6
- require 'git/command_line'
7
- require 'git/errors'
8
- require 'git/parsers/branch'
9
- require 'git/parsers/fsck'
10
- require 'git/parsers/grep'
11
- require 'git/parsers/stash'
12
- require 'git/parsers/tag'
13
- require 'git/url'
14
- require 'logger'
15
- require 'pathname'
16
- require 'pp'
17
- require 'process_executer'
18
- require 'stringio'
19
- require 'tempfile'
20
- require 'zlib'
21
-
22
- module Git
23
- # Internal git operations
24
- # @api private
25
- class Lib
26
- # Thread-safe cache for git versions, keyed by binary path
27
- @git_version_cache_mutex = Mutex.new
28
- @git_version_cache = {}
29
-
30
- # The path to the Git working copy. The default is '"./.git"'.
31
- #
32
- # @return [Pathname] the path to the Git working copy.
33
- #
34
- # @see [Git working tree](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefworkingtreeaworkingtree)
35
- #
36
- attr_reader :git_work_dir
37
-
38
- # The path to the Git repository directory. The default is
39
- # `"#{git_work_dir}/.git"`.
40
- #
41
- # @return [Pathname] the Git repository directory.
42
- #
43
- # @see [Git repository](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefrepositoryarepository)
44
- #
45
- attr_reader :git_dir
46
-
47
- # The Git index file used to stage changes (using `git add`) before they
48
- # are committed.
49
- #
50
- # @return [Pathname] the Git index file
51
- #
52
- # @see [Git index file](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefindexaindex)
53
- #
54
- attr_reader :git_index_file
55
-
56
- # Create a new Git::Lib object
57
- #
58
- # @overload initialize(base, logger)
59
- #
60
- # @param base [Hash] the hash containing paths to the Git working copy,
61
- # the Git repository directory, and the Git index file.
62
- #
63
- # @option base [Pathname] :working_directory
64
- # @option base [Pathname] :repository
65
- # @option base [Pathname] :index
66
- #
67
- # @param [Logger] logger
68
- #
69
- # @overload initialize(base, logger)
70
- #
71
- # @param base [#dir, #repo, #index] an object with methods to get the Git worktree (#dir),
72
- # the Git repository directory (#repo), and the Git index file (#index).
73
- #
74
- # @param [Logger] logger
75
- #
76
- def initialize(base = nil, logger = nil)
77
- @logger = logger || Logger.new(nil)
78
- @git_ssh = :use_global_config
79
-
80
- case base
81
- when Git::Base
82
- initialize_from_base(base)
83
- when Hash
84
- initialize_from_hash(base)
85
- end
86
- end
87
-
88
- # Creates or reinitializes the repository in the current directory
89
- #
90
- # This is a low-level method that just runs `git init` with the given options.
91
- # For full repository initialization including directory creation and path
92
- # resolution, use Git.init instead.
93
- #
94
- # @param opts [Hash] command options
95
- #
96
- # @option opts [Boolean, nil] :bare (nil) create a bare repository
97
- #
98
- # @option opts [String, nil] :initial_branch (nil) use the specified name for the initial branch
99
- #
100
- # @option opts [String, nil] :separate_git_dir (nil) path to put the .git directory (`--separate-git-dir`)
101
- #
102
- # @option opts [String, nil] :repository (nil) deprecated — use `:separate_git_dir` instead
103
- #
104
- # @return [String] the command output
105
- #
106
- def init(opts = {})
107
- opts = opts.dup
108
- opts[:separate_git_dir] ||= opts.delete(:repository)
109
- Git::Commands::Init.new(self).call(**opts).stdout
110
- end
111
-
112
- # Clones a repository into a newly created directory
113
- #
114
- # @param [String] repository_url the URL of the repository to clone
115
- #
116
- # @param [String, nil] directory the directory to clone into
117
- #
118
- # If nil, the repository is cloned into a directory with the same name as
119
- # the repository.
120
- #
121
- # @param [Hash] opts the options for this command
122
- #
123
- # @option opts [Boolean, nil] :bare (nil) if true, clone as a bare repository
124
- #
125
- # @option opts [String, nil] :branch (nil) the branch to checkout
126
- #
127
- # @option opts [String, Array<String>, nil] :config (nil) one or more configuration options to set
128
- #
129
- # @option opts [Integer, nil] :depth (nil) the number of commits back to pull
130
- #
131
- # @option opts [String, nil] :filter (nil) specify partial clone
132
- #
133
- # @option opts [String, nil] :git_ssh (nil) SSH command or binary to use for git over SSH
134
- #
135
- # @option opts [Logger, nil] :log (nil) Logger instance to use for git operations
136
- #
137
- # @option opts [Boolean, nil] :mirror (nil) set up a mirror of the source repository
138
- #
139
- # @option opts [String, nil] :origin (nil) the name of the remote
140
- #
141
- # @option opts [String, nil] :chdir (nil) run `git clone` from this directory
142
- #
143
- # When given, `directory` (or the repository basename when `directory` is nil)
144
- # is resolved relative to `:chdir`, just as if you had `cd`'d into it before
145
- # running `git clone`. The returned path is the join of `:chdir` and the
146
- # cloned directory path.
147
- #
148
- # @option opts [String] :path deprecated: use `:chdir` instead.
149
- #
150
- # @option opts [String] :remote deprecated: use `:origin` instead.
151
- #
152
- # @option opts [Boolean, String, Array<String>, nil] :recurse_submodules (nil) initialize
153
- # submodules after cloning; pass `true` for all submodules, or a pathspec string/array
154
- # for a subset
155
- #
156
- # @option opts [Boolean, String, Array<String>, nil] :recursive (nil) deprecated: use `:recurse_submodules` instead
157
- #
158
- # @option opts [Numeric, nil] :timeout (nil) the number of seconds to wait for the
159
- # command to complete
160
- #
161
- # See {Git::Lib#command} for more information about :timeout
162
- #
163
- # @return [Hash] the options to pass to {Git::Base.new}
164
- #
165
- # @todo make this work with SSH password or auth_key
166
- #
167
- def clone(repository_url, directory = nil, opts = {})
168
- opts = opts.dup
169
- deprecate_clone_options!(opts)
170
- chdir = opts.delete(:chdir)
171
- execution_opts = extract_clone_execution_context_opts(opts)
172
- opts[:chdir] = chdir if chdir
173
- command_line_result = Git::Commands::Clone.new(self).call(repository_url, directory, **opts)
174
- result = build_clone_result(command_line_result, execution_opts)
175
- prefix_clone_result_paths!(result, chdir)
176
- result
177
- end
178
-
179
- # Returns the name of the default branch of the given repository
180
- #
181
- # @param repository [URI, Pathname, String] The (possibly remote) repository to clone from
182
- #
183
- # @return [String] the name of the default branch
184
- #
185
- def repository_default_branch(repository)
186
- output = Git::Commands::LsRemote.new(self).call(repository, 'HEAD', symref: true).stdout
187
-
188
- match_data = output.match(%r{^ref: refs/remotes/origin/(?<default_branch>[^\t]+)\trefs/remotes/origin/HEAD$})
189
- return match_data[:default_branch] if match_data
190
-
191
- match_data = output.match(%r{^ref: refs/heads/(?<default_branch>[^\t]+)\tHEAD$})
192
- return match_data[:default_branch] if match_data
193
-
194
- raise Git::UnexpectedResultError, 'Unable to determine the default branch'
195
- end
196
-
197
- ## READ COMMANDS ##
198
-
199
- # Finds most recent tag that is reachable from a commit
200
- #
201
- # @see https://git-scm.com/docs/git-describe git-describe
202
- #
203
- # @param commit_ish [String, nil] target commit sha or object name
204
- #
205
- # @param opts [Hash] the given options
206
- #
207
- # @option opts [Boolean, nil] :all (nil) use refs from all branches, tags, and remotes
208
- # @option opts [Boolean, nil] :tags (nil) consider any tag, not just annotated ones
209
- # @option opts [Boolean, nil] :contains (nil) find the tag that comes after the commit
210
- # @option opts [Boolean, nil] :debug (nil) enable verbose searching strategy output
211
- # @option opts [Boolean, nil] :long (nil) always output the long format
212
- # @option opts [Boolean, nil] :always (nil) show uniquely abbreviated commit as a fallback
213
- # @option opts [Boolean, nil] :exact_match (nil) only output exact tag matches
214
- # @option opts :dirty [true, String]
215
- # @option opts :abbrev [String]
216
- # @option opts :candidates [String]
217
- # @option opts :match [String]
218
- #
219
- # @return [String] the tag name
220
- #
221
- # @raise [ArgumentError] if the commit_ish is a string starting with a hyphen
222
- #
223
- def describe(commit_ish = nil, opts = {})
224
- assert_args_are_not_options('commit-ish object', commit_ish)
225
-
226
- # Translate legacy :"exact-match" (hyphenated) key to :exact_match (underscored)
227
- opts = opts.dup
228
- opts[:exact_match] ||= opts.delete(:'exact-match') if opts.key?(:'exact-match')
229
-
230
- commit_ishes = Array(commit_ish).compact
231
- Git::Commands::Describe.new(self).call(*commit_ishes, **opts).stdout
232
- end
233
-
234
- # Return the commits that are within the given revision range
235
- #
236
- # @see https://git-scm.com/docs/git-log git-log
237
- #
238
- # @param opts [Hash] the given options
239
- #
240
- # @option opts :count [Integer] the maximum number of commits to return (maps to
241
- # max-count)
242
- #
243
- # @option opts [Boolean, nil] :all (nil) include commits reachable from any ref
244
- #
245
- # @option opts [Boolean, nil] :cherry (nil) omit commits equivalent to cherry-picked commits
246
- #
247
- # @option opts :since [String]
248
- #
249
- # @option opts :until [String]
250
- #
251
- # @option opts :grep [String]
252
- #
253
- # @option opts :author [String]
254
- #
255
- # @option opts :between [Array<String>] an array of two commit-ish strings to
256
- # specify a revision range
257
- #
258
- # Only :between or :object options can be used, not both.
259
- #
260
- # @option opts :object [String] the revision range for the git log command
261
- #
262
- # Only :between or :object options can be used, not both.
263
- #
264
- # @option opts :path_limiter [String, Pathname, Array<String, Pathname>] only include commits that
265
- # impact files from the specified paths
266
- #
267
- # @option opts :skip [Integer]
268
- #
269
- # @return [Array<Hash>] the log output parsed into an array of hashs for each commit
270
- #
271
- # Each hash contains the following keys:
272
- #
273
- # * 'sha' [String] the commit sha
274
- # * 'author' [String] the author of the commit
275
- # * 'message' [String] the commit message
276
- # * 'parent' [Array<String>] the commit shas of the parent commits
277
- # * 'tree' [String] the tree sha
278
- # * 'author' [String] the author of the commit and timestamp of when the
279
- # changes were created
280
- # * 'committer' [String] the committer of the commit and timestamp of when the
281
- # commit was applied
282
- # * 'merges' [Boolean] if truthy, only include merge commits (aka commits with
283
- # 2 or more parents)
284
- #
285
- # @raise [ArgumentError] if the revision range (specified with :between or
286
- # :object) is a string starting with a hyphen
287
- #
288
- def full_log_commits(opts = {})
289
- assert_valid_opts(opts, FULL_LOG_ALLOWED_OPTS)
290
- validate_log_count_option!(opts)
291
-
292
- call_opts = log_base_call_options(opts, skip: opts[:skip], merges: opts[:merges])
293
- run_log_command(log_revision_range_args(opts), call_opts)
294
- end
295
-
296
- # Verify and resolve a Git revision to its full SHA
297
- #
298
- # @see https://git-scm.com/docs/git-rev-parse git-rev-parse
299
- # @see https://git-scm.com/docs/git-rev-parse#_specifying_revisions Valid ways to specify revisions
300
- # @see https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt-emltrefnamegtemegemmasterememheadsmasterememrefsheadsmasterem
301
- # Ref disambiguation rules
302
- #
303
- # @example
304
- # lib.rev_parse('HEAD') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d'
305
- # lib.rev_parse('9b9b31e') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d'
306
- #
307
- # @param revision [String] the revision to resolve
308
- #
309
- # @return [String] the full commit hash
310
- #
311
- # @raise [Git::FailedError] if the revision cannot be resolved
312
- #
313
- def rev_parse(revision)
314
- Git::Commands::RevParse.new(self).call(revision, '--', revs_only: true).stdout
315
- end
316
-
317
- # For backwards compatibility with the old method name
318
- alias revparse rev_parse
319
-
320
- # Find the first symbolic name for given commit_ish
321
- #
322
- # @param commit_ish [String] the commit_ish to find the symbolic name of
323
- #
324
- # @return [String, nil] the first symbolic name or nil if the commit_ish isn't found
325
- #
326
- # @raise [ArgumentError] if the commit_ish is a string starting with a hyphen
327
- #
328
- def name_rev(commit_ish)
329
- assert_args_are_not_options('commit_ish', commit_ish)
330
-
331
- Git::Commands::NameRev.new(self).call(commit_ish).stdout.split[1]
332
- end
333
-
334
- alias namerev name_rev
335
-
336
- # Returns the raw content of a git object, or streams it into a tempfile
337
- #
338
- # Without a block, the full content is buffered in memory and returned as a
339
- # `String`. With a block, git output is streamed directly to disk without memory
340
- # buffering — safe for large blobs.
341
- #
342
- # @see https://git-scm.com/docs/git-cat-file git-cat-file
343
- #
344
- # @overload cat_file_contents(object)
345
- # Returns the object's raw content as a string.
346
- #
347
- # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
348
- #
349
- # @return [String] the raw content of the object
350
- #
351
- # @raise [ArgumentError] if `object` starts with a hyphen
352
- #
353
- # @raise [Git::FailedError] if the object does not exist or the command fails
354
- #
355
- # @example Get the contents of a blob
356
- # lib.cat_file_contents('HEAD:README.md') # => "This is a README file\n"
357
- #
358
- # @overload cat_file_contents(object, &block)
359
- # Streams the object's raw content to a temporary file and yields it.
360
- #
361
- # Git output is written directly to a file on disk without being
362
- # buffered in memory first, then the file is rewound and yielded to the block.
363
- # The return value is whatever the block returns.
364
- #
365
- # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
366
- #
367
- # @yield [file] the temporary file containing the streamed content, positioned at the start
368
- #
369
- # @yieldparam file [File] readable `IO` object positioned at the beginning of the content
370
- #
371
- # @yieldreturn [Object] the value to return from this method
372
- #
373
- # @return [Object] the value returned by the block
374
- #
375
- # @raise [ArgumentError] if `object` starts with a hyphen
376
- #
377
- # @raise [Git::FailedError] if the object does not exist or the command fails
378
- #
379
- # @example Read a large blob without buffering it in memory
380
- # lib.cat_file_contents('HEAD:large_file.bin') { |f| process(f) }
381
- #
382
- def cat_file_contents(object)
383
- assert_args_are_not_options('object', object)
384
-
385
- return Git::Commands::CatFile::Raw.new(self).call(object, p: true).stdout unless block_given?
386
-
387
- # Stream git output directly to a tempfile to avoid buffering large
388
- # object content in memory when a block is given.
389
- Tempfile.create do |file|
390
- file.binmode
391
- Git::Commands::CatFile::Raw.new(self).call(object, p: true, out: file)
392
- file.rewind
393
- yield file
394
- end
395
- end
396
-
397
- alias object_contents cat_file_contents
398
-
399
- # Get the type for the given object
400
- #
401
- # @see https://git-scm.com/docs/git-cat-file git-cat-file
402
- #
403
- # @param object [String] the object to get the type
404
- #
405
- # @return [String] the object type
406
- #
407
- # @raise [ArgumentError] if object is a string starting with a hyphen
408
- #
409
- def cat_file_type(object)
410
- assert_args_are_not_options('object', object)
411
-
412
- cat_file_object_meta(object)[:type]
413
- end
414
-
415
- alias object_type cat_file_type
416
-
417
- # Get the size for the given object
418
- #
419
- # @see https://git-scm.com/docs/git-cat-file git-cat-file
420
- #
421
- # @param object [String] the object to get the size of
422
- #
423
- # @return [Integer] the object size in bytes
424
- #
425
- # @raise [ArgumentError] if object is a string starting with a hyphen
426
- #
427
- def cat_file_size(object)
428
- assert_args_are_not_options('object', object)
429
-
430
- cat_file_object_meta(object)[:size]
431
- end
432
-
433
- alias object_size cat_file_size
434
-
435
- # Return a hash of commit data
436
- #
437
- # @see https://git-scm.com/docs/git-cat-file git-cat-file
438
- #
439
- # @param object [String] the object to get the type
440
- #
441
- # @return [Hash] commit data
442
- #
443
- # The returned commit data has the following keys:
444
- # * tree [String]
445
- # * parent [Array<String>]
446
- # * author [String] the author name, email, and commit timestamp
447
- # * committer [String] the committer name, email, and merge timestamp
448
- # * message [String] the commit message
449
- # * gpgsig [String] the public signing key of the commit (if signed)
450
- #
451
- # @raise [ArgumentError] if object is a string starting with a hyphen
452
- #
453
- def cat_file_commit(object)
454
- assert_args_are_not_options('object', object)
455
-
456
- cdata = Git::Commands::CatFile::Raw.new(self).call('commit', object).stdout.split("\n")
457
- process_commit_data(cdata, object)
458
- end
459
-
460
- alias commit_data cat_file_commit
461
-
462
- def process_commit_data(data, sha)
463
- # process_commit_headers consumes the header lines from the `data` array,
464
- # leaving only the message lines behind.
465
- headers = process_commit_headers(data)
466
- message = "#{data.join("\n")}\n"
467
-
468
- { 'sha' => sha, 'message' => message }.merge(headers)
469
- end
470
-
471
- CAT_FILE_HEADER_LINE = /\A(?<key>\w+) (?<value>.*)\z/
472
-
473
- # Yields parsed header key/value pairs from `git cat-file` output lines
474
- #
475
- # Consumes header lines from the front of `data` until a non-header line is
476
- # encountered. Continuation lines that begin with a space are folded into the
477
- # previous header value using newline separators.
478
- #
479
- # @param data [Array<String>] mutable output lines from a cat-file response
480
- #
481
- # @yield [key, value] each parsed header pair
482
- #
483
- # @yieldparam key [String] header field name
484
- #
485
- # @yieldparam value [String] unfolded header value text
486
- #
487
- # @yieldreturn [void]
488
- #
489
- # @return [void]
490
- #
491
- # @raise [NoMethodError] if `data` contains non-string entries
492
- #
493
- def each_cat_file_header(data)
494
- while (match = CAT_FILE_HEADER_LINE.match(data.shift))
495
- key = match[:key]
496
- value_lines = [match[:value]]
497
-
498
- value_lines << data.shift.lstrip while data.first.start_with?(' ')
499
-
500
- yield key, value_lines.join("\n")
501
- end
502
- end
503
-
504
- # Return a hash of annotated tag data
505
- #
506
- # Does not work with lightweight tags. List all annotated tags in your repository
507
- # with the following command:
508
- #
509
- # ```sh
510
- # git for-each-ref --format='%(refname:strip=2)' refs/tags | \
511
- # while read tag; do git cat-file tag $tag >/dev/null 2>&1 && echo $tag; done
512
- # ```
513
- #
514
- # @see https://git-scm.com/docs/git-cat-file git-cat-file
515
- #
516
- # @param object [String] the tag to retrieve
517
- #
518
- # @return [Hash] tag data
519
- #
520
- # Example tag data returned:
521
- # ```ruby
522
- # {
523
- # "name" => "annotated_tag",
524
- # "object" => "46abbf07e3c564c723c7c039a43ab3a39e5d02dd",
525
- # "type" => "commit",
526
- # "tag" => "annotated_tag",
527
- # "tagger" => "Scott Chacon <schacon@gmail.com> 1724799270 -0700",
528
- # "message" => "Creating an annotated tag\n"
529
- # }
530
- # ```
531
- #
532
- # The returned commit data has the following keys:
533
- # * object [String] the sha of the tag object
534
- # * type [String]
535
- # * tag [String] tag name
536
- # * tagger [String] the name and email of the user who created the tag
537
- # and the timestamp of when the tag was created
538
- # * message [String] the tag message
539
- #
540
- # @raise [ArgumentError] if object is a string starting with a hyphen
541
- #
542
- def cat_file_tag(object)
543
- assert_args_are_not_options('object', object)
544
-
545
- tdata = Git::Commands::CatFile::Raw.new(self).call('tag', object).stdout.split("\n")
546
- process_tag_data(tdata, object)
547
- end
548
-
549
- alias tag_data cat_file_tag
550
-
551
- def cat_file_object_meta(object)
552
- stdout = Git::Commands::CatFile::Batch.new(self).call(object, batch_check: true).stdout
553
- parse_cat_file_meta(stdout, object)
554
- end
555
-
556
- def parse_cat_file_meta(output, object)
557
- line = output.to_s.lines.first.to_s.chomp
558
-
559
- request_object_to_raise_error!(object) if line == "#{object} missing"
560
-
561
- match = /\A\S+ (?<type>\S+) (?<size>\d+)\z/.match(line)
562
- raise Git::UnexpectedResultError, "unexpected git cat-file metadata output: #{line.inspect}" if match.nil?
563
-
564
- {
565
- type: match[:type],
566
- size: match[:size].to_i
567
- }
568
- end
569
-
570
- # Re-request the missing object via non-batch cat-file so git produces a
571
- # real non-zero exit and a FailedError with an accurate stderr message.
572
- def request_object_to_raise_error!(object)
573
- Git::Commands::CatFile::Raw.new(self).call(object, p: true)
574
- raise Git::UnexpectedResultError,
575
- "expected git cat-file to raise Git::FailedError for missing object #{object.inspect}"
576
- end
577
-
578
- def process_tag_data(data, name)
579
- hsh = { 'name' => name }
580
-
581
- each_cat_file_header(data) do |key, value|
582
- hsh[key] = value
583
- end
584
-
585
- hsh['message'] = "#{data.join("\n")}\n"
586
-
587
- hsh
588
- end
589
-
590
- def process_commit_log_data(data)
591
- RawLogParser.new(data).parse
592
- end
593
-
594
- # A private parser class to process the output of `git log --pretty=raw`
595
- # @api private
596
- class RawLogParser
597
- def initialize(lines)
598
- @lines = lines
599
- @commits = []
600
- @current_commit = nil
601
- @in_message = false
602
- end
603
-
604
- def parse
605
- @lines.each { |line| process_line(line.chomp) }
606
- finalize_commit
607
- @commits
608
- end
609
-
610
- private
611
-
612
- def process_line(line)
613
- if line.empty?
614
- @in_message = !@in_message
615
- return
616
- end
617
-
618
- @in_message = false if @in_message && !line.start_with?(' ')
619
-
620
- @in_message ? process_message_line(line) : process_metadata_line(line)
621
- end
622
-
623
- def process_message_line(line)
624
- @current_commit['message'] << "#{line[4..]}\n"
625
- end
626
-
627
- def process_metadata_line(line)
628
- key, *value = line.split
629
- value = value.join(' ')
630
-
631
- case key
632
- when 'commit'
633
- start_new_commit(value)
634
- when 'parent'
635
- @current_commit['parent'] << value
636
- else
637
- @current_commit[key] = value
638
- end
639
- end
640
-
641
- def start_new_commit(sha)
642
- finalize_commit
643
- @current_commit = { 'sha' => sha, 'message' => +'', 'parent' => [] }
644
- end
645
-
646
- def finalize_commit
647
- @commits << @current_commit if @current_commit
648
- end
649
- end
650
- private_constant :RawLogParser
651
-
652
- # Allowed option keys for {#ls_tree}
653
- LS_TREE_ALLOWED_OPTS = %i[recursive path].freeze
654
-
655
- # Lists the objects in a git tree
656
- #
657
- # @param sha [String] the tree-ish object to list
658
- #
659
- # @param opts [Hash] additional options
660
- #
661
- # @return [Hash<String, Hash<String, Hash>>] parsed ls-tree output
662
- #
663
- # @api private
664
- #
665
- def ls_tree(sha, opts = {})
666
- assert_valid_opts(opts, LS_TREE_ALLOWED_OPTS)
667
- r_value = opts[:recursive]
668
- paths = Array(opts[:path]).compact
669
- safe_options = {}
670
- safe_options[:r] = r_value unless r_value.nil?
671
- result = Git::Commands::LsTree.new(self).call(sha, *paths, **safe_options)
672
- parse_ls_tree_output(result.stdout)
673
- end
674
-
675
- def parse_ls_tree_output(output)
676
- data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
677
- output.split("\n").each do |line|
678
- (info, filenm) = split_status_line(line)
679
- (mode, type, entry_sha) = info.split
680
- data[type][filenm] = { mode: mode, sha: entry_sha }
681
- end
682
- data
683
- end
684
- private :parse_ls_tree_output
685
-
686
- # @return [String] the command output
687
- #
688
- def mv(source, destination, options = {})
689
- Git::Commands::Mv.new(self).call(*Array(source), destination, verbose: true, **options).stdout
690
- end
691
-
692
- def full_tree(sha)
693
- Git::Commands::LsTree.new(self).call(sha, r: true).stdout.split("\n")
694
- end
695
-
696
- def tree_depth(sha)
697
- full_tree(sha).size
698
- end
699
-
700
- def change_head_branch(branch_name)
701
- Git::Commands::SymbolicRef::Update.new(self).call('HEAD', "refs/heads/#{branch_name}")
702
- end
703
-
704
- def branches_all
705
- result = Git::Commands::Branch::List.new(self).call(all: true, format: Git::Parsers::Branch::FORMAT_STRING)
706
- Git::Parsers::Branch.parse_list(result.stdout)
707
- end
708
-
709
- def worktrees_all
710
- arr = []
711
- directory = ''
712
- # Output example for `worktree list --porcelain`:
713
- # worktree /code/public/ruby-git
714
- # HEAD 4bef5abbba073c77b4d0ccc1ffcd0ed7d48be5d4
715
- # branch refs/heads/master
716
- #
717
- # worktree /tmp/worktree-1
718
- # HEAD b8c63206f8d10f57892060375a86ae911fad356e
719
- # detached
720
- #
721
- Git::Commands::Worktree::List.new(self).call(porcelain: true).stdout.split("\n").each do |w|
722
- s = w.split
723
- directory = s[1] if s[0] == 'worktree'
724
- arr << [directory, s[1]] if s[0] == 'HEAD'
725
- end
726
- arr
727
- end
728
-
729
- def worktree_add(dir, commitish = nil)
730
- if commitish.nil?
731
- Git::Commands::Worktree::Add.new(self).call(dir).stdout
732
- else
733
- Git::Commands::Worktree::Add.new(self).call(dir, commitish).stdout
734
- end
735
- end
736
-
737
- def worktree_remove(dir)
738
- Git::Commands::Worktree::Remove.new(self).call(dir).stdout
739
- end
740
-
741
- def worktree_prune
742
- Git::Commands::Worktree::Prune.new(self).call.stdout
743
- end
744
-
745
- def list_files(ref_dir)
746
- dir = File.join(@git_dir, 'refs', ref_dir)
747
- Dir.glob('**/*', base: dir).select { |f| File.file?(File.join(dir, f)) }
748
- end
749
-
750
- # The state and name of branch pointed to by `HEAD`
751
- #
752
- # HEAD can be in the following states:
753
- #
754
- # **:active**: `HEAD` points to a branch reference which in turn points to a
755
- # commit representing the tip of that branch. This is the typical state when
756
- # working on a branch.
757
- #
758
- # **:unborn**: `HEAD` points to a branch reference that does not yet exist
759
- # because no commits have been made on that branch. This state occurs in two
760
- # scenarios:
761
- #
762
- # * When a repository is newly initialized, and no commits have been made on the
763
- # initial branch.
764
- # * When a new branch is created using `git checkout --orphan <branch>`, starting
765
- # a new branch with no history.
766
- #
767
- # **:detached**: `HEAD` points directly to a specific commit (identified by its
768
- # SHA) rather than a branch reference. This state occurs when you check out a
769
- # commit, a tag, or any state that is not directly associated with a branch. The
770
- # branch name in this case is `HEAD`.
771
- #
772
- HeadState = Struct.new(:state, :name)
773
-
774
- # The current branch state which is the state of `HEAD`
775
- #
776
- # @return [HeadState] the state and name of the current branch
777
- #
778
- def current_branch_state
779
- branch_name = Git::Commands::Branch::ShowCurrent.new(self).call.stdout
780
- return HeadState.new(:detached, 'HEAD') if branch_name.empty?
781
-
782
- state = get_branch_state(branch_name)
783
- HeadState.new(state, branch_name)
784
- end
785
-
786
- def branch_current
787
- result = Git::Commands::Branch::ShowCurrent.new(self).call
788
- name = result.stdout.strip
789
- name.empty? ? 'HEAD' : name
790
- end
791
-
792
- def branch_contains(commit, branch_name = '')
793
- branch_name = branch_name.to_s
794
- pattern = branch_name.empty? ? nil : branch_name
795
- Git::Commands::Branch::List.new(self).call(*[pattern].compact, contains: commit, no_color: true).stdout
796
- end
797
-
798
- GREP_ALLOWED_OPTS = %i[ignore_case i invert_match v extended_regexp E object path_limiter].freeze
799
-
800
- def grep(pattern, opts = {})
801
- assert_valid_opts(opts, GREP_ALLOWED_OPTS)
802
-
803
- opts = normalize_grep_opts(opts)
804
- object = opts.delete(:object) || 'HEAD'
805
- result = Git::Commands::Grep.new(self).call(
806
- object, pattern:, **opts, no_color: true, line_number: true, null: true
807
- )
808
- exitstatus = result.status.exitstatus
809
-
810
- # Exit status 1 with empty stderr means no lines matched (not an error)
811
- return {} if exitstatus == 1 && result.stderr.empty?
812
-
813
- # Exit status 1 with non-empty stderr is a real error (e.g. bad object reference)
814
- raise Git::FailedError, result if exitstatus == 1
815
-
816
- parse_grep_output(result.stdout)
817
- end
818
-
819
- # Validate that the given arguments cannot be mistaken for a command-line option
820
- #
821
- # @param arg_name [String] the name of the arguments to mention in the error message
822
- # @param args [Array<String, nil>] the arguments to validate
823
- #
824
- # @raise [ArgumentError] if any of the parameters are a string starting with a hyphen
825
- # @return [void]
826
- #
827
- def assert_args_are_not_options(arg_name, *args)
828
- invalid_args = args.select { |arg| arg&.start_with?('-') }
829
- return unless invalid_args.any?
830
-
831
- raise ArgumentError, "Invalid #{arg_name}: '#{invalid_args.join("', '")}'"
832
- end
833
-
834
- # Normalizes path specifications for Git commands
835
- #
836
- # Converts a single path or array of paths into a consistent array format
837
- # suitable for appending to Git command arguments after '--'. Empty strings
838
- # are filtered out after conversion.
839
- #
840
- # @param pathspecs [String, Pathname, Array<String, Pathname>, nil] path(s) to normalize
841
- # @param arg_name [String] name of the argument for error messages
842
- # @return [Array<String>, nil] normalized array of path strings, or nil if empty/nil input
843
- # @raise [ArgumentError] if any path is not a String or Pathname
844
- #
845
- def normalize_pathspecs(pathspecs, arg_name)
846
- return nil unless pathspecs
847
-
848
- normalized = Array(pathspecs)
849
- validate_pathspec_types(normalized, arg_name)
850
-
851
- normalized = normalized.map(&:to_s).reject(&:empty?)
852
- return nil if normalized.empty?
853
-
854
- normalized
855
- end
856
-
857
- # Validates that all pathspecs are String or Pathname objects
858
- #
859
- # @param pathspecs [Array] the pathspecs to validate
860
- # @param arg_name [String] name of the argument for error messages
861
- # @raise [ArgumentError] if any path is not a String or Pathname
862
- #
863
- def validate_pathspec_types(pathspecs, arg_name)
864
- return if pathspecs.all? { |path| path.is_a?(String) || path.is_a?(Pathname) }
865
-
866
- raise ArgumentError, "Invalid #{arg_name}: must be a String, Pathname, or Array of Strings/Pathnames"
867
- end
868
-
869
- # Allowed option keys for {#full_log_commits}
870
- FULL_LOG_ALLOWED_OPTS = %i[count all cherry since until grep author between object path_limiter skip merges].freeze
871
-
872
- # Allowed option keys for {#diff_full}
873
- DIFF_FULL_ALLOWED_OPTS = %i[path_limiter].freeze
874
-
875
- # Allowed option keys for {#diff_stats}
876
- DIFF_STATS_ALLOWED_OPTS = %i[path_limiter].freeze
877
-
878
- # Allowed option keys for {#diff_path_status}
879
- DIFF_PATH_STATUS_ALLOWED_OPTS = %i[path_limiter path].freeze
880
-
881
- # Handle deprecated :path option in favor of :path_limiter
882
- #
883
- # @param opts [Hash] options hash that may contain :path or :path_limiter
884
- #
885
- # @return [String, Pathname, Array<String, Pathname>, nil] the resolved path limiter
886
- #
887
- def handle_deprecated_path_option(opts)
888
- if opts.key?(:path_limiter)
889
- opts[:path_limiter]
890
- elsif opts.key?(:path)
891
- Git::Deprecation.warn(
892
- 'Git::Lib#diff_path_status :path option is deprecated. Use :path_limiter instead.'
893
- )
894
- opts[:path]
895
- end
896
- end
897
-
898
- # Validate that opts contains only allowed keys
899
- #
900
- # @param opts [Hash] options hash to validate
901
- #
902
- # @param allowed [Array<Symbol>] allowed option keys
903
- #
904
- # @raise [ArgumentError] if unknown keys are present
905
- #
906
- def assert_valid_opts(opts, allowed)
907
- unknown = opts.keys - allowed
908
- raise ArgumentError, "Unknown options: #{unknown.join(', ')}" if unknown.any?
909
- end
910
-
911
- # Show full diff patch output between commits or the working tree
912
- #
913
- # Delegates to {Git::Commands::Diff}.
914
- #
915
- # @param obj1 [String] first commit reference (default: 'HEAD')
916
- #
917
- # @param obj2 [String, nil] second commit reference (default: nil)
918
- #
919
- # @param opts [Hash] options
920
- #
921
- # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter (nil)
922
- # pathspecs to limit the diff
923
- #
924
- # @return [String] the unified diff patch output
925
- #
926
- # @raise [Git::FailedError] if git returns exit code > 2
927
- #
928
- # @see Git::Commands::Diff
929
- #
930
- def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
931
- assert_valid_opts(opts, DIFF_FULL_ALLOWED_OPTS)
932
- pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter')
933
- result = Git::Commands::Diff.new(self).call(
934
- *[obj1, obj2].compact,
935
- patch: true, numstat: true, shortstat: true,
936
- src_prefix: 'a/', dst_prefix: 'b/',
937
- path: pathspecs
938
- )
939
- extract_patch_text(result.stdout)
940
- end
941
-
942
- # Show numstat diff output between commits or the working tree
943
- #
944
- # Delegates to {Git::Commands::Diff}.
945
- #
946
- # @param obj1 [String] first commit reference (default: 'HEAD')
947
- #
948
- # @param obj2 [String, nil] second commit reference (default: nil)
949
- #
950
- # @param opts [Hash] options
951
- #
952
- # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter (nil)
953
- # pathspecs to limit the diff
954
- #
955
- # @return [Hash] diff statistics with the shape:
956
- # `{ total: { insertions:, deletions:, lines:, files: }, files: { ... } }`
957
- #
958
- # @raise [Git::FailedError] if git returns exit code > 2
959
- #
960
- # @see Git::Commands::Diff
961
- #
962
- def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
963
- assert_valid_opts(opts, DIFF_STATS_ALLOWED_OPTS)
964
- pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter')
965
- result = Git::Commands::Diff.new(self).call(
966
- *[obj1, obj2].compact,
967
- numstat: true, shortstat: true,
968
- src_prefix: 'a/', dst_prefix: 'b/',
969
- path: pathspecs
970
- )
971
- output_lines = extract_numstat_lines(result.stdout)
972
- parse_diff_stats_output(output_lines)
973
- end
974
-
975
- # Show path status (name-status) for diff between commits or the working tree
976
- #
977
- # Delegates to {Git::Commands::Diff} and extracts status letters and
978
- # paths from the raw output lines.
979
- #
980
- # @param reference1 [String, nil] first commit reference (default: nil)
981
- #
982
- # @param reference2 [String, nil] second commit reference (default: nil)
983
- #
984
- # @param opts [Hash] options
985
- #
986
- # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter (nil)
987
- # pathspecs to limit the diff
988
- #
989
- # @option opts [String, Pathname, Array<String, Pathname>] :path (nil)
990
- # deprecated; use :path_limiter instead
991
- #
992
- # @return [Hash] mapping of file paths to status letters
993
- # (e.g. `{ "lib/foo.rb" => "M", "README.md" => "A" }`)
994
- #
995
- # @raise [Git::FailedError] if git returns exit code > 2
996
- #
997
- # @see Git::Commands::Diff
998
- #
999
- def diff_path_status(reference1 = nil, reference2 = nil, opts = {})
1000
- assert_valid_opts(opts, DIFF_PATH_STATUS_ALLOWED_OPTS)
1001
-
1002
- path_limiter = handle_deprecated_path_option(opts)
1003
- pathspecs = normalize_pathspecs(path_limiter, 'path limiter')
1004
- result = Git::Commands::Diff.new(self).call(
1005
- *[reference1, reference2].compact,
1006
- raw: true, numstat: true, shortstat: true,
1007
- src_prefix: 'a/', dst_prefix: 'b/',
1008
- path: pathspecs
1009
- )
1010
- extract_name_status_from_raw(result.stdout)
1011
- end
1012
-
1013
- # compares the index and the working directory
1014
- def diff_files
1015
- Git::Commands::Status.new(self).call
1016
- parse_raw_diff_output(Git::Commands::DiffFiles.new(self).call.stdout)
1017
- end
1018
-
1019
- # compares the index and the repository
1020
- def diff_index(treeish)
1021
- Git::Commands::Status.new(self).call
1022
- parse_raw_diff_output(Git::Commands::DiffIndex.new(self).call(treeish).stdout)
1023
- end
1024
-
1025
- # List all files that are in the index
1026
- #
1027
- # @param location [String] the location to list the files from
1028
- #
1029
- # @return [Hash<String, Hash>] a hash of files in the index
1030
- # * key: file [String] the file path
1031
- # * value: file_info [Hash] the file information containing the following keys:
1032
- # * :path [String] the file path
1033
- # * :mode_index [String] the file mode
1034
- # * :sha_index [String] the file sha
1035
- # * :stage [String] the file stage
1036
- #
1037
- def ls_files(location = nil)
1038
- location ||= '.'
1039
- {}.tap do |files|
1040
- Git::Commands::LsFiles.new(self).call(location, stage: true).stdout.split("\n").each do |line|
1041
- (info, file) = split_status_line(line)
1042
- (mode, sha, stage) = info.split
1043
- files[file] = {
1044
- path: file, mode_index: mode, sha_index: sha, stage: stage
1045
- }
1046
- end
1047
- end
1048
- end
1049
-
1050
- # Unescape a path if it is quoted
1051
- #
1052
- # Git commands that output paths (e.g. ls-files, diff), will escape unusual
1053
- # characters.
1054
- #
1055
- # @example
1056
- # lib.unescape_if_quoted('"quoted_file_\\342\\230\\240"') # => 'quoted_file_☠'
1057
- # lib.unescape_if_quoted('unquoted_file') # => 'unquoted_file'
1058
- #
1059
- # @param path [String] the path to unescape if quoted
1060
- #
1061
- # @return [String] the unescaped path if quoted otherwise the original path
1062
- #
1063
- # @api private
1064
- #
1065
- def unescape_quoted_path(path)
1066
- if path.start_with?('"') && path.end_with?('"')
1067
- Git::EscapedPath.new(path[1..-2]).unescape
1068
- else
1069
- path
1070
- end
1071
- end
1072
-
1073
- def ls_remote(location = nil, opts = {})
1074
- repository = location || '.'
1075
- output_lines = Git::Commands::LsRemote.new(self).call(repository, **opts).stdout.split("\n")
1076
- parse_ls_remote_output(output_lines)
1077
- end
1078
-
1079
- def ignored_files
1080
- Git::Commands::LsFiles.new(self).call(
1081
- others: true, ignored: true, exclude_standard: true
1082
- ).stdout.split("\n").map { |f| unescape_quoted_path(f) }
1083
- end
1084
-
1085
- def untracked_files
1086
- Git::Commands::LsFiles.new(self).call(
1087
- others: true, exclude_standard: true, chdir: @git_work_dir
1088
- ).stdout.split("\n").map { |f| unescape_quoted_path(f) }
1089
- end
1090
-
1091
- def config_remote(name)
1092
- hsh = {}
1093
- config_list.each do |key, value|
1094
- hsh[key.gsub("remote.#{name}.", '')] = value if /remote.#{name}/.match(key)
1095
- end
1096
- hsh
1097
- end
1098
-
1099
- def config_get(name)
1100
- result = Git::Commands::ConfigOptionSyntax::Get.new(self).call(name)
1101
- raise Git::FailedError, result if result.status.exitstatus != 0
1102
-
1103
- result.stdout
1104
- end
1105
-
1106
- def global_config_get(name)
1107
- result = Git::Commands::ConfigOptionSyntax::Get.new(self).call(name, global: true)
1108
- raise Git::FailedError, result if result.status.exitstatus != 0
1109
-
1110
- result.stdout
1111
- end
1112
-
1113
- def config_list
1114
- parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call.stdout.split("\n")
1115
- end
1116
-
1117
- def global_config_list
1118
- parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call(global: true).stdout.split("\n")
1119
- end
1120
-
1121
- def parse_config_list(lines)
1122
- hsh = {}
1123
- lines.each do |line|
1124
- (key, *values) = line.split('=')
1125
- hsh[key] = values.join('=')
1126
- end
1127
- hsh
1128
- end
1129
-
1130
- def parse_config(file)
1131
- parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call(file: file).stdout.split("\n")
1132
- end
1133
-
1134
- # Shows objects
1135
- #
1136
- # @param [String|NilClass] objectish the target object reference (nil == HEAD)
1137
- # @param [String|NilClass] path the path of the file to be shown
1138
- # @return [String] the object information
1139
- def show(objectish = nil, path = nil)
1140
- object = path ? "#{objectish}:#{path}" : objectish
1141
- Git::Commands::Show.new(self).call(*[object].compact).stdout
1142
- end
1143
-
1144
- ## WRITE COMMANDS ##
1145
-
1146
- CONFIG_SET_ALLOWED_OPTS = %i[file].freeze
1147
-
1148
- def config_set(name, value, options = {})
1149
- assert_valid_opts(options, CONFIG_SET_ALLOWED_OPTS)
1150
- Git::Commands::ConfigOptionSyntax::Set.new(self).call(name, value, **options.slice(*CONFIG_SET_ALLOWED_OPTS))
1151
- end
1152
-
1153
- def global_config_set(name, value)
1154
- Git::Commands::ConfigOptionSyntax::Set.new(self).call(name, value, global: true)
1155
- end
1156
-
1157
- # Update the index from the current worktree to prepare the for the next commit
1158
- #
1159
- # @example
1160
- # lib.add('path/to/file')
1161
- # lib.add(['path/to/file1','path/to/file2'])
1162
- # lib.add(:all => true)
1163
- #
1164
- # @param [String, Array<String>] paths files to be added to the repository (relative to the worktree root)
1165
- # @param [Hash] options
1166
- #
1167
- # @option options [Boolean, nil] :all (nil) add, modify, and remove index entries to match the worktree
1168
- # @option options [Boolean, nil] :force (nil) allow adding otherwise ignored files
1169
- #
1170
- # @return [String] the command output (typically empty on success)
1171
- #
1172
- def add(paths = '.', options = {})
1173
- Git::Commands::Add.new(self).call(*Array(paths), **options).stdout
1174
- end
1175
-
1176
- # Remove files from the working tree and from the index
1177
- #
1178
- # @param path [String, Array<String>] files or directories to remove
1179
- # @param opts [Hash] command options
1180
- #
1181
- # @option opts [Boolean, nil] :force (nil) force removal, bypassing the up-to-date check; alias: `:f`
1182
- # @option opts [Boolean, nil] :recursive (nil) remove directories and their contents recursively
1183
- # @option opts [Boolean, nil] :cached (nil) only remove from the index, keeping working tree files
1184
- #
1185
- # @return [String] the command output
1186
- #
1187
- def rm(path = '.', opts = {})
1188
- Git::Commands::Rm.new(self).call(*Array(path), **opts).stdout
1189
- end
1190
-
1191
- # Returns true if the repository is empty (meaning it has no commits)
1192
- #
1193
- # @return [Boolean]
1194
- #
1195
- def empty?
1196
- Git::Commands::RevParse.new(self).call('HEAD', verify: true)
1197
- false
1198
- rescue Git::FailedError => e
1199
- raise unless e.result.status.exitstatus == 128 &&
1200
- e.result.stderr == 'fatal: Needed a single revision'
1201
-
1202
- true
1203
- end
1204
-
1205
- # Takes the commit message with the options and executes the commit command
1206
- #
1207
- # accepts options:
1208
- # :amend
1209
- # :all
1210
- # :allow_empty
1211
- # :author
1212
- # :date
1213
- # :no_verify
1214
- # :allow_empty_message
1215
- # :gpg_sign (accepts true or a gpg key ID as a String)
1216
- # :no_gpg_sign (conflicts with :gpg_sign)
1217
- #
1218
- # @param [String] message the commit message to be used
1219
- # @param [Hash] opts the commit options to be used
1220
- #
1221
- def commit(message, opts = {})
1222
- opts = opts.merge(message: message) if message
1223
- deprecate_commit_add_all_option!(opts)
1224
- Git::Commands::Commit.new(self).call(no_edit: true, **opts).stdout
1225
- end
1226
-
1227
- # @return [String] the command output
1228
- #
1229
- def reset(commit = nil, opts = {})
1230
- Git::Commands::Reset.new(self).call(commit, **opts).stdout
1231
- end
1232
-
1233
- # @return [String] the command output
1234
- #
1235
- def clean(opts = {})
1236
- opts = migrate_clean_legacy_options(opts)
1237
- Git::Commands::Clean.new(self).call(**opts).stdout
1238
- end
1239
-
1240
- REVERT_ALLOWED_OPTS = %i[no_edit].freeze
1241
-
1242
- def revert(commitish, opts = {})
1243
- assert_valid_opts(opts, REVERT_ALLOWED_OPTS)
1244
- opts = { no_edit: true }.merge(opts)
1245
- Git::Commands::Revert::Start.new(self).call(commitish, **opts).stdout
1246
- end
1247
-
1248
- def apply(patch_file)
1249
- Git::Commands::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1250
- end
1251
-
1252
- def apply_mail(patch_file)
1253
- Git::Commands::Am::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1254
- end
1255
-
1256
- # Returns all stash entries as an array of index and message pairs
1257
- #
1258
- # List all stash entries in the repository ordered from oldest to newest
1259
- #
1260
- # The index is a sequential number starting from 0 for the oldest stash, and the
1261
- # message is the description of the stash entry.
1262
- #
1263
- # @example List all stashes (oldest first)
1264
- # lib.stashes_all # => [[0, "Fix bug"], [1, "Add feature"]]
1265
- #
1266
- # @return [Array<Array(Integer, String)>] array of [index, message] pairs where
1267
- # index is the sequential position (0 is oldest) and message is the stash description
1268
- #
1269
- # @see https://git-scm.com/docs/git-stash git-stash documentation
1270
- #
1271
- def stashes_all
1272
- result = Git::Commands::Stash::List.new(self).call
1273
- stashes = Git::Parsers::Stash.parse_list(result.stdout)
1274
- stashes.reverse.each_with_index.map { |info, i| stash_info_to_legacy(info, i) }
1275
- end
1276
-
1277
- # Save the current working directory and index state to a new stash
1278
- #
1279
- # This method preserves v4.0.0 backward compatibility by returning a truthy/falsy
1280
- # value indicating whether a stash was created.
1281
- #
1282
- # @param message [String] the stash message
1283
- #
1284
- # @return [Boolean] true if changes were stashed, false if there were no local changes to save
1285
- #
1286
- # @example Save current changes
1287
- # lib.stash_save('WIP: feature work')
1288
- #
1289
- # @see https://git-scm.com/docs/git-stash git-stash documentation
1290
- #
1291
- def stash_save(message) # rubocop:disable Naming/PredicateMethod
1292
- result = Git::Commands::Stash::Push.new(self).call(message: message)
1293
- !result.stdout.include?('No local changes to save')
1294
- end
1295
-
1296
- # Apply a stash to the working directory
1297
- #
1298
- # This method preserves v4.0.0 backward compatibility by returning the command output.
1299
- #
1300
- # @param id [String, Integer, nil] the stash identifier (e.g., 'stash@\\{0}', 0) or nil for latest
1301
- #
1302
- # @return [String] the output from the git stash apply command
1303
- #
1304
- # @example Apply the latest stash
1305
- # lib.stash_apply
1306
- #
1307
- # @example Apply a specific stash
1308
- # lib.stash_apply('stash@{1}')
1309
- #
1310
- # @see https://git-scm.com/docs/git-stash git-stash documentation
1311
- #
1312
- def stash_apply(id = nil)
1313
- result = Git::Commands::Stash::Apply.new(self).call(id)
1314
- result.stdout
1315
- end
1316
-
1317
- # Remove all stash entries
1318
- #
1319
- # This method preserves v4.0.0 backward compatibility by returning the command output.
1320
- #
1321
- # @return [String] the output from the git stash clear command
1322
- #
1323
- # @example Clear all stashes
1324
- # lib.stash_clear
1325
- #
1326
- # @see https://git-scm.com/docs/git-stash git-stash documentation
1327
- #
1328
- def stash_clear
1329
- result = Git::Commands::Stash::Clear.new(self).call
1330
- result.stdout
1331
- end
1332
-
1333
- # List all stash entries in standard git stash list format
1334
- #
1335
- # This method preserves v4.0.0 backward compatibility by returning a formatted
1336
- # string matching the output of `git stash list`.
1337
- #
1338
- # @return [String] newline-separated list of stash entries in the format
1339
- # "stash@\\{n}: <message>", or an empty string if no stashes exist
1340
- #
1341
- # @example List all stashes
1342
- # lib.stash_list # => "stash@\\{0}: On main: WIP\nstash@\\{1}: On feature: test"
1343
- #
1344
- # @see https://git-scm.com/docs/git-stash git-stash documentation
1345
- #
1346
- def stash_list
1347
- result = Git::Commands::Stash::List.new(self).call
1348
- stashes = Git::Parsers::Stash.parse_list(result.stdout)
1349
- stashes.map { |info| "#{info.name}: #{info.message}" }.join("\n")
1350
- end
1351
-
1352
- # Create a new branch
1353
- #
1354
- # @param branch [String] the name of the branch to create
1355
- # @param start_point [String, nil] the commit, branch, or tag to start the new branch from
1356
- # @param options [Hash] command options (see {Git::Commands::Branch::Create#call})
1357
- #
1358
- # @return [nil]
1359
- #
1360
- def branch_new(branch, start_point = nil, options = {})
1361
- Git::Commands::Branch::Create.new(self).call(branch, start_point, **options)
1362
- nil
1363
- end
1364
-
1365
- # Delete one or more branches
1366
- #
1367
- # @param branches [Array<String>] the name(s) of the branch(es) to delete
1368
- # @param options [Hash] command options (see {Git::Commands::Branch::Delete#call})
1369
- # @option options [Boolean, nil] :force (nil) allow deleting unmerged branches (defaults to `true` when not given)
1370
- # @option options [Boolean, nil] :remotes (nil) delete remote-tracking branches
1371
- #
1372
- # @return [String] newline-separated list of "Deleted branch <name> (was <sha>)." messages
1373
- #
1374
- # @raise [Git::Error] if any branch fails to delete
1375
- #
1376
- def branch_delete(*branches, **options)
1377
- options = { force: true }.merge(options)
1378
- result = Git::Commands::Branch::Delete.new(self).call(*branches, **options)
1379
-
1380
- raise Git::Error, result.stderr.strip unless result.status.success?
1381
-
1382
- result.stdout.strip
1383
- end
1384
-
1385
- # Runs checkout command to checkout or create branch
1386
- #
1387
- # accepts options:
1388
- # :new_branch / :b - create a new branch with the given name (true = legacy, string = new)
1389
- # :force / :f - proceed even with uncommitted changes
1390
- # :start_point - start the new branch at this commit (used with :new_branch in legacy mode)
1391
- #
1392
- # @param [String] branch the branch to checkout, or nil
1393
- # @param [Hash] opts options for the checkout command
1394
- # @return [String] the command output
1395
- #
1396
- def checkout(branch = nil, opts = {})
1397
- if branch.is_a?(Hash) && opts.empty?
1398
- opts = branch
1399
- branch = nil
1400
- end
1401
-
1402
- target, translated_opts = translate_checkout_opts(branch, opts)
1403
- Git::Commands::Checkout::Branch.new(self).call(target, **translated_opts).stdout
1404
- end
1405
-
1406
- # Translates legacy checkout options to the new command interface.
1407
- # Legacy: checkout('branch', new_branch: true, start_point: 'main')
1408
- # New: checkout('main', b: 'branch')
1409
- def translate_checkout_opts(branch, opts)
1410
- if opts[:new_branch] == true || opts[:b] == true
1411
- [opts[:start_point], opts.except(:new_branch, :b, :start_point).merge(b: branch)]
1412
- elsif opts[:new_branch].is_a?(String)
1413
- [branch, opts.except(:new_branch).merge(b: opts[:new_branch])]
1414
- else
1415
- [branch, opts]
1416
- end
1417
- end
1418
- private :translate_checkout_opts
1419
-
1420
- # Checkout a specific version of a file
1421
- #
1422
- # @param version [String] the tree-ish (commit, branch, tag) to restore from
1423
- # @param file [String] the file path to restore
1424
- # @return [String] the command output
1425
- #
1426
- def checkout_file(version, file)
1427
- Git::Commands::Checkout::Files.new(self).call(version, pathspec: [file]).stdout
1428
- end
1429
-
1430
- # Merge one or more branches into the current branch
1431
- #
1432
- # @param branch [String, Array<String>] branch name(s) to merge
1433
- # @param message [String, nil] commit message for merge commit
1434
- # @param opts [Hash] merge options
1435
- #
1436
- # @option opts [Boolean, nil] :no_commit (nil) stop before creating merge commit
1437
- # (deprecated: use no_commit: true instead)
1438
- # @option opts [Boolean, nil] :no_ff (nil) create merge commit even for fast-forward
1439
- # (deprecated: use no_ff: true instead)
1440
- # @option opts [String] :m (nil) commit message (deprecated: use message: option)
1441
- # @option opts [Boolean, nil] :commit (nil) true for --commit (`--commit`)
1442
- # @option opts [Boolean, nil] :ff (nil) true for --ff (`--ff`)
1443
- # @option opts [Boolean, nil] :ff_only (nil) only merge if fast-forward possible
1444
- # @option opts [Boolean, nil] :squash (nil) squash commits into single commit
1445
- # @option opts [String] :message (nil) commit message
1446
- # @option opts [String] :strategy (nil) merge strategy (e.g., 'ort', 'ours')
1447
- # @option opts [String, Array<String>] :strategy_option (nil) strategy-specific options
1448
- # @option opts [Boolean, nil] :allow_unrelated_histories (nil) allow merging unrelated histories
1449
- #
1450
- # @return [String] the command output
1451
- #
1452
- def merge(branch, message = nil, opts = {})
1453
- # Handle legacy positional message argument
1454
- opts = opts.merge(message: message) if message
1455
-
1456
- # Map legacy option names to new interface
1457
- opts = translate_merge_options(opts)
1458
-
1459
- Git::Commands::Merge::Start.new(self).call(*Array(branch), no_edit: true, **opts).stdout
1460
- end
1461
-
1462
- # Find common ancestor commit(s) for merge
1463
- #
1464
- # @overload merge_base(*commits, options = {})
1465
- #
1466
- # @param commits [Array<String>] commits to find common ancestor(s) of
1467
- #
1468
- # @param options [Hash] merge-base options
1469
- #
1470
- # @option options [Boolean, nil] :octopus (nil) compute best ancestor for n-way merge
1471
- # @option options [Boolean, nil] :independent (nil) list commits not reachable from others
1472
- # @option options [Boolean, nil] :fork_point (nil) find fork point
1473
- # @option options [Boolean, nil] :all (nil) output all merge bases
1474
- #
1475
- # @return [Array<String>] array of commit SHAs
1476
- #
1477
- def merge_base(*args)
1478
- opts = args.last.is_a?(Hash) ? args.pop : {}
1479
- result = Git::Commands::MergeBase.new(self).call(*args, **opts)
1480
- result.stdout.lines.map(&:strip).reject(&:empty?)
1481
- end
1482
-
1483
- # List paths that remain unmerged after a failed or partial merge
1484
- #
1485
- # Delegates to {Git::Commands::Diff}.
1486
- #
1487
- # @return [Array<String>] paths of files with unresolved merge conflicts
1488
- #
1489
- # @raise [Git::FailedError] if git returns exit code > 2
1490
- #
1491
- # @see Git::Commands::Diff
1492
- #
1493
- def unmerged
1494
- result = Git::Commands::Diff.new(self).call(cached: true)
1495
- result.stdout.split("\n").filter_map do |line|
1496
- ::Regexp.last_match(1) if line =~ /^\* Unmerged path (.*)/
1497
- end
1498
- end
1499
-
1500
- def conflicts # :yields: file, your, their
1501
- unmerged.each do |file_path|
1502
- Tempfile.create(['YOUR-', File.basename(file_path)]) do |your_file|
1503
- write_staged_content(file_path, 2, your_file).flush
1504
-
1505
- Tempfile.create(['THEIR-', File.basename(file_path)]) do |their_file|
1506
- write_staged_content(file_path, 3, their_file).flush
1507
- yield(file_path, your_file.path, their_file.path)
1508
- end
1509
- end
1510
- end
1511
- end
1512
-
1513
- def remote_add(name, url, opts = {})
1514
- translated_opts = opts.dup
1515
- translated_opts[:fetch] = translated_opts.delete(:with_fetch) if translated_opts.key?(:with_fetch)
1516
-
1517
- Git::Commands::Remote::Add.new(self).call(name, url, **translated_opts)
1518
- end
1519
-
1520
- def remote_set_branches(name, branches, opts = {})
1521
- Git::Commands::Remote::SetBranches.new(self).call(name, *Array(branches).flatten, **opts)
1522
- end
1523
-
1524
- def remote_set_url(name, url, opts = {})
1525
- Git::Commands::Remote::SetUrl.new(self).call(name, url, **opts)
1526
- end
1527
-
1528
- def remote_remove(name)
1529
- Git::Commands::Remote::Remove.new(self).call(name)
1530
- end
1531
-
1532
- def remotes
1533
- Git::Commands::Remote::List.new(self).call.stdout.split("\n")
1534
- end
1535
-
1536
- # List all tags in the repository
1537
- #
1538
- # @see https://git-scm.com/docs/git-tag git-tag
1539
- #
1540
- # @return [Array<String>] tag names
1541
- #
1542
- def tags
1543
- result = Git::Commands::Tag::List.new(self).call(format: Git::Parsers::Tag::FORMAT_STRING)
1544
- Git::Parsers::Tag.parse_list(result.stdout).map(&:name)
1545
- end
1546
-
1547
- # Create or delete a tag
1548
- #
1549
- # When the `:d` or `:delete` option is set, deletes the named tag.
1550
- # Otherwise, creates a new tag pointing at HEAD or the specified target.
1551
- #
1552
- # @see https://git-scm.com/docs/git-tag git-tag
1553
- #
1554
- # @overload tag(name, target, opts = {})
1555
- #
1556
- # Create a tag on the specified target
1557
- #
1558
- # @param name [String] the tag name to create
1559
- #
1560
- # @param target [String] the commit or object to tag
1561
- #
1562
- # @param opts [Hash] options for creating the tag
1563
- #
1564
- # @option opts [Boolean, nil] :annotate (nil) create an unsigned, annotated tag object.
1565
- # Requires `:message` or `:file`.
1566
- #
1567
- # Alias: `:a`
1568
- #
1569
- # @option opts [Boolean, nil] :sign (nil) create a GPG-signed tag. Requires `:message` or `:file`.
1570
- #
1571
- # Alias: `:s`
1572
- #
1573
- # @option opts [Boolean, nil] :force (nil) replace an existing tag with the given name.
1574
- #
1575
- # Alias: `:f`
1576
- #
1577
- # @option opts [String] :message (nil) use the given string as the tag message.
1578
- # Implies annotated tag if none of `:annotate`, `:sign`, or `:local_user` is given.
1579
- #
1580
- # Alias: `:m`
1581
- #
1582
- # @overload tag(name, opts = {})
1583
- #
1584
- # Create a lightweight tag on HEAD
1585
- #
1586
- # @param name [String] the tag name to create
1587
- #
1588
- # @param opts [Hash] options for creating the tag
1589
- #
1590
- # @option opts [Boolean, nil] :annotate (nil) create an unsigned, annotated tag object.
1591
- # Requires `:message` or `:file`.
1592
- #
1593
- # Alias: `:a`
1594
- #
1595
- # @option opts [Boolean, nil] :sign (nil) create a GPG-signed tag. Requires `:message` or `:file`.
1596
- #
1597
- # Alias: `:s`
1598
- #
1599
- # @option opts [Boolean, nil] :force (nil) replace an existing tag with the given name.
1600
- #
1601
- # Alias: `:f`
1602
- #
1603
- # @option opts [String] :message (nil) use the given string as the tag message.
1604
- # Implies annotated tag if none of `:annotate`, `:sign`, or `:local_user` is given.
1605
- #
1606
- # Alias: `:m`
1607
- #
1608
- # @overload tag(name, opts = {})
1609
- #
1610
- # Delete the named tag
1611
- #
1612
- # @param name [String] the tag name to delete
1613
- #
1614
- # @param opts [Hash] options
1615
- #
1616
- # @option opts [Boolean, nil] :delete (nil) delete the named tag.
1617
- #
1618
- # Alias: `:d`
1619
- #
1620
- # @return [String] command output
1621
- #
1622
- # @raise [ArgumentError] if creating an annotated or signed tag without a message
1623
- #
1624
- # @raise [Git::FailedError] if the tag already exists (without `:force`) or if
1625
- # the tag to delete does not exist
1626
- #
1627
- def tag(name, *args)
1628
- opts = args.last.is_a?(Hash) ? args.pop : {}
1629
- target = args.first
1630
-
1631
- if opts[:d] || opts[:delete]
1632
- delete_tag(name)
1633
- else
1634
- validate_tag_options!(opts)
1635
- create_tag(name, target, opts)
1636
- end
1637
- end
1638
-
1639
- def fetch(remote, opts)
1640
- opts = opts.dup
1641
- refspecs = Array(opts.delete(:ref)).compact
1642
- positionals = [*([remote] if remote), *refspecs]
1643
- Git::Commands::Fetch.new(self).call(*positionals, **opts, merge: true).stdout
1644
- end
1645
-
1646
- PUSH_ALLOWED_OPTS = %i[mirror delete force f push_option all tags].freeze
1647
-
1648
- # Push refs to a remote repository
1649
- #
1650
- # @overload push(options = {})
1651
- # Push using the current branch's default remote and push configuration
1652
- #
1653
- # @param options [Hash] push options
1654
- #
1655
- # @option options [Boolean, nil] :all (nil) push all branches
1656
- #
1657
- # @option options [Boolean, nil] :mirror (nil) push all refs
1658
- #
1659
- # @option options [Boolean, nil] :tags (nil) push all tags
1660
- #
1661
- # @option options [Boolean, nil] :force (nil) force updates
1662
- #
1663
- # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1664
- #
1665
- # @option options [String, Array<String>] :push_option (nil) server-side push option values
1666
- #
1667
- # @return [String] the stdout from the final `git push` invocation
1668
- #
1669
- # @raise [Git::FailedError] if git exits with a non-zero exit status
1670
- #
1671
- # @overload push(remote, options = {})
1672
- # Push to the given remote using the current branch's default push configuration
1673
- #
1674
- # @param remote [String] the remote name or URL to push to
1675
- #
1676
- # @param options [Hash] push options
1677
- #
1678
- # @option options [Boolean, nil] :all (nil) push all branches
1679
- #
1680
- # @option options [Boolean, nil] :mirror (nil) push all refs
1681
- #
1682
- # @option options [Boolean, nil] :tags (nil) push all tags
1683
- #
1684
- # @option options [Boolean, nil] :force (nil) force updates
1685
- #
1686
- # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1687
- #
1688
- # @option options [String, Array<String>] :push_option (nil) server-side push option values
1689
- #
1690
- # @return [String] the stdout from the final `git push` invocation
1691
- #
1692
- # @raise [Git::FailedError] if git exits with a non-zero exit status
1693
- #
1694
- # @overload push(remote, branch, options = {})
1695
- # Push a branch or refspec to the given remote
1696
- #
1697
- # @param remote [String] the remote name or URL to push to
1698
- #
1699
- # @param branch [String] the branch name or refspec to push
1700
- #
1701
- # @param options [Hash] push options
1702
- #
1703
- # @option options [Boolean, nil] :all (nil) push all branches
1704
- #
1705
- # @option options [Boolean, nil] :mirror (nil) push all refs
1706
- #
1707
- # @option options [Boolean, nil] :tags (nil) push all tags
1708
- #
1709
- # @option options [Boolean, nil] :force (nil) force updates
1710
- #
1711
- # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1712
- #
1713
- # @option options [String, Array<String>] :push_option (nil) server-side push option values
1714
- #
1715
- # @return [String] the stdout from the final `git push` invocation
1716
- #
1717
- # @raise [Git::FailedError] if git exits with a non-zero exit status
1718
- #
1719
- # @raise [ArgumentError] if `remote` is nil
1720
- #
1721
- # @overload push(remote, branch, tags)
1722
- # Backward-compatible shorthand for `push(remote, branch, tags: tags)`
1723
- #
1724
- # @param remote [String] the remote name or URL to push to
1725
- #
1726
- # @param branch [String] the branch name or refspec to push
1727
- #
1728
- # @param tags [Boolean] whether to push all tags
1729
- #
1730
- # @return [String] the stdout from the final `git push` invocation
1731
- #
1732
- # @raise [Git::FailedError] if git exits with a non-zero exit status
1733
- #
1734
- # @raise [ArgumentError] if `remote` is nil
1735
- #
1736
- def push(remote = nil, branch = nil, opts = nil)
1737
- remote, branch, opts = normalize_push_args(remote, branch, opts)
1738
- validate_push_args!(remote, branch, opts)
1739
-
1740
- first_result = push_refs(remote, branch, opts)
1741
- return first_result.stdout unless push_tags_separately?(opts)
1742
-
1743
- push_tags(remote, opts).stdout
1744
- end
1745
-
1746
- PULL_ALLOWED_OPTS = %i[allow_unrelated_histories].freeze
1747
-
1748
- def pull(remote = nil, branch = nil, opts = {})
1749
- raise ArgumentError, 'You must specify a remote if a branch is specified' if remote.nil? && !branch.nil?
1750
-
1751
- assert_valid_opts(opts, PULL_ALLOWED_OPTS)
1752
- allowed_opts = opts.slice(*PULL_ALLOWED_OPTS)
1753
- positional_args = [remote, branch].compact
1754
- Git::Commands::Pull.new(self).call(*positional_args, no_edit: true, no_progress: true, **allowed_opts).stdout
1755
- end
1756
-
1757
- # Return the SHA of a tag reference
1758
- #
1759
- # Looks up the tag first in the local refs directory, then falls back to
1760
- # `git show-ref`. Returns an empty string if the tag does not exist.
1761
- #
1762
- # @param tag_name [String] the tag name to look up
1763
- #
1764
- # @return [String] the SHA of the tag, or an empty string if not found
1765
- #
1766
- def tag_sha(tag_name)
1767
- head = File.join(@git_dir, 'refs', 'tags', tag_name)
1768
- return File.read(head).chomp if File.exist?(head)
1769
-
1770
- result = Git::Commands::ShowRef::List.new(self).call(tag_name, tags: true, hash: true)
1771
- result.stdout
1772
- end
1773
-
1774
- def repack
1775
- Git::Commands::Repack.new(self).call(a: true, d: true)
1776
- end
1777
-
1778
- def gc
1779
- Git::Commands::Gc.new(self).call(prune: true, aggressive: true, auto: true)
1780
- end
1781
-
1782
- # Execute git fsck to verify repository integrity
1783
- #
1784
- # @param objects [Array<String>] optional object identifiers to check
1785
- # @param opts [Hash] command options (see {Git::Commands::Fsck#call})
1786
- #
1787
- # @return [Git::FsckResult] the structured result
1788
- #
1789
- # rubocop:disable Style/ArgumentsForwarding
1790
- def fsck(*objects, **opts)
1791
- result = Git::Commands::Fsck.new(self).call(*objects, no_progress: true, **opts)
1792
- Git::Parsers::Fsck.parse(result.stdout)
1793
- end
1794
- # rubocop:enable Style/ArgumentsForwarding
1795
-
1796
- READ_TREE_ALLOWED_OPTS = %i[prefix].freeze
1797
-
1798
- def read_tree(treeish, opts = {})
1799
- assert_valid_opts(opts, READ_TREE_ALLOWED_OPTS)
1800
- allowed_opts = opts.slice(*READ_TREE_ALLOWED_OPTS)
1801
- Git::Commands::ReadTree.new(self).call(treeish, **allowed_opts)
1802
- end
1803
-
1804
- def write_tree
1805
- Git::Commands::WriteTree.new(self).call.stdout
1806
- end
1807
-
1808
- COMMIT_TREE_ALLOWED_OPTS = %i[p parent parents m message].freeze
1809
-
1810
- def commit_tree(tree, opts = {})
1811
- assert_valid_opts(opts, COMMIT_TREE_ALLOWED_OPTS)
1812
- actual_opts = normalize_commit_tree_opts(opts, tree)
1813
- Git::Commands::CommitTree.new(self).call(tree, **actual_opts).stdout
1814
- end
1815
-
1816
- def update_ref(ref, commit)
1817
- Git::Commands::UpdateRef::Update.new(self).call(ref, commit)
1818
- end
1819
-
1820
- def checkout_index(opts = {})
1821
- paths = normalize_pathspecs(opts[:path_limiter], 'path_limiter')
1822
- keyword_opts = opts.except(:path_limiter)
1823
- Git::Commands::CheckoutIndex.new(self).call(*paths.to_a, **keyword_opts)
1824
- end
1825
-
1826
- ARCHIVE_ALLOWED_OPTS = %i[prefix remote path format add_gzip].freeze
1827
-
1828
- # Creates an archive of the given tree-ish and writes it to a file
1829
- #
1830
- # Delegates to {Git::Commands::Archive} for CLI execution. Format coercion
1831
- # (`tgz` → `tar` + gzip), temp file management, and gzip post-processing
1832
- # remain in this adapter.
1833
- #
1834
- # @see https://git-scm.com/docs/git-archive git-archive
1835
- #
1836
- # @param sha [String] tree-ish to archive (commit, tag, branch, or tree SHA)
1837
- #
1838
- # @param file [String, nil] destination file path; a unique temp file is
1839
- # created and returned if `nil`
1840
- #
1841
- # @param opts [Hash] archive options
1842
- #
1843
- # @option opts [String] :prefix prefix to prepend to each filename in the archive
1844
- #
1845
- # @option opts [String] :remote URL of a remote repository to archive from
1846
- #
1847
- # @option opts [String] :path limit the archive to a path within the tree
1848
- #
1849
- # @option opts [String] :format archive format — `'tar'`, `'tgz'`, or `'zip'`
1850
- # (default: `'zip'`)
1851
- #
1852
- # @option opts [Boolean, nil] :add_gzip (nil) wrap the archive in gzip compression
1853
- #
1854
- # @return [String] the path to the written archive file
1855
- #
1856
- # @raise [Git::FailedError] if `git archive` fails
1857
- #
1858
- def archive(sha, file = nil, opts = {})
1859
- assert_valid_opts(opts, ARCHIVE_ALLOWED_OPTS)
1860
- file ||= temp_file_name
1861
- format, gzip = parse_archive_format_options(opts)
1862
-
1863
- command_opts = opts.slice(:prefix, :remote).merge(format: format)
1864
- path_args = opts[:path] ? [opts[:path]] : []
1865
-
1866
- File.open(file, 'wb') do |f|
1867
- Git::Commands::Archive.new(self).call(sha, *path_args, **command_opts, out: f)
1868
- end
1869
- apply_gzip(file) if gzip
1870
-
1871
- file
1872
- end
1873
-
1874
- # Returns the git version as a Git::Version
1875
- #
1876
- # Parses the output of `git version`, strips platform suffixes (like
1877
- # `.windows.1` or `.vfs.0`), and pads two-segment versions to three segments.
1878
- #
1879
- # Results are cached globally (keyed by binary path). It is assumed that the
1880
- # git version doesn't change during runtime for a given binary.
1881
- #
1882
- # @return [Git::Version] the parsed git version
1883
- #
1884
- # @raise [Git::UnexpectedResultError] if the version string cannot be parsed
1885
- #
1886
- # @example
1887
- # lib.git_version #=> Git::Version.new(2, 42, 1)
1888
- #
1889
- def git_version
1890
- self.class.cached_git_version(Git::Base.config.binary_path) do
1891
- output = Git::Commands::Version.new(self).call.stdout
1892
- Git::Version.parse(output)
1893
- end
1894
- end
1895
-
1896
- # Class-level cache for git versions, keyed by binary path
1897
- #
1898
- # Thread-safe for JRuby/TruffleRuby where true parallelism exists.
1899
- #
1900
- # @api private
1901
- #
1902
- def self.cached_git_version(binary_path, &block)
1903
- @git_version_cache_mutex.synchronize do
1904
- @git_version_cache[binary_path] ||= block.call
1905
- end
1906
- end
1907
-
1908
- # Clear the git version cache (primarily for testing)
1909
- #
1910
- # @api private
1911
- #
1912
- def self.clear_git_version_cache
1913
- @git_version_cache_mutex.synchronize do
1914
- @git_version_cache.clear
1915
- end
1916
- end
1917
-
1918
- # Returns the current version of git, as an Array<Integer>
1919
- #
1920
- # @deprecated Use {Git.git_version} instead, which returns a {Git::Version} (not an Array).
1921
- # For the legacy array shape, call: `Git.git_version.to_a`
1922
- #
1923
- def current_command_version
1924
- Git::Deprecation.warn(
1925
- 'Git::Lib#current_command_version is deprecated and will be removed in 6.0. ' \
1926
- 'Use Git.git_version instead, which returns a Git::Version (not an Array). ' \
1927
- 'For the legacy array shape, call: Git.git_version.to_a'
1928
- )
1929
- git_version.to_a
1930
- end
1931
-
1932
- # Returns current_command_version <=> other_version
1933
- #
1934
- # @example
1935
- # lib.compare_version_to(2, 41, 0) #=> 1
1936
- # lib.compare_version_to(2, 42, 0) #=> 0
1937
- # lib.compare_version_to(2, 43, 0) #=> -1
1938
- #
1939
- # @param other_version [Array<Integer>] the other version to compare to
1940
- # @return [Integer] -1 if this version is less than other_version, 0 if equal, or 1 if greater than
1941
- #
1942
- # @deprecated Use {Git.git_version} with {Git::Version} comparison operators instead,
1943
- # e.g. `Git.git_version <=> Git::Version.new(2, 41, 0)`
1944
- #
1945
- def compare_version_to(*other_version)
1946
- Git::Deprecation.warn(
1947
- 'Git::Lib#compare_version_to is deprecated and will be removed in 6.0. ' \
1948
- 'Use Git.git_version with Git::Version comparison operators instead, ' \
1949
- 'e.g. Git.git_version <=> Git::Version.new(2, 41, 0)'
1950
- )
1951
- git_version.to_a <=> other_version
1952
- end
1953
-
1954
- # @deprecated Use {Git::MINIMUM_GIT_VERSION} constant instead, which returns a {Git::Version}
1955
- # (not an Array). For the legacy array shape, call: `Git::MINIMUM_GIT_VERSION.to_a.first(2)`
1956
- #
1957
- def required_command_version
1958
- Git::Deprecation.warn(
1959
- 'Git::Lib#required_command_version is deprecated and will be removed in 6.0. ' \
1960
- 'Use the Git::MINIMUM_GIT_VERSION constant instead, which returns a Git::Version ' \
1961
- '(not an Array). For the legacy array shape, call: Git::MINIMUM_GIT_VERSION.to_a.first(2)'
1962
- )
1963
- Git::MINIMUM_GIT_VERSION.to_a.first(2)
1964
- end
1965
-
1966
- # @deprecated For a boolean check, use `Git.git_version >= Git::MINIMUM_GIT_VERSION`.
1967
- # For enforcement, no action is needed: {Git::Commands::Base#call} automatically
1968
- # invokes `validate_version!`, which raises {Git::VersionError} on failure.
1969
- #
1970
- def meets_required_version?
1971
- Git::Deprecation.warn(
1972
- 'Git::Lib#meets_required_version? is deprecated and will be removed in 6.0. ' \
1973
- 'For a boolean check, use: Git.git_version >= Git::MINIMUM_GIT_VERSION. ' \
1974
- 'For enforcement, no action is needed: Git::Commands::Base#call automatically ' \
1975
- 'invokes validate_version!, which raises Git::VersionError on failure.'
1976
- )
1977
- git_version >= Git::MINIMUM_GIT_VERSION
1978
- end
1979
-
1980
- # @deprecated Version validation is now handled automatically by
1981
- # {Git::Commands::Base#validate_version!}, which raises {Git::VersionError} on failure.
1982
- # Callers wanting the old warn-and-continue behavior must implement it themselves
1983
- # using: `Git.git_version >= Git::MINIMUM_GIT_VERSION`.
1984
- #
1985
- def self.warn_if_old_command(_lib) # rubocop:disable Metrics/MethodLength, Naming/PredicateMethod
1986
- Git::Deprecation.warn(
1987
- 'Git::Lib.warn_if_old_command is deprecated and will be removed in 6.0. ' \
1988
- 'Version validation is now handled automatically by Git::Commands::Base#validate_version!, ' \
1989
- 'which RAISES Git::VersionError on failure (the old method only printed a warning ' \
1990
- 'once per process and continued). Callers wanting the old warn-and-continue behavior ' \
1991
- 'must implement it themselves using: Git.git_version >= Git::MINIMUM_GIT_VERSION.'
1992
- )
1993
-
1994
- return true if @version_checked
1995
-
1996
- @version_checked = true
1997
- git_version = Git.git_version
1998
- unless git_version >= Git::MINIMUM_GIT_VERSION
1999
- warn "The git gem requires git #{Git::MINIMUM_GIT_VERSION} or later, " \
2000
- "but only found #{git_version}. You should probably upgrade."
2001
- end
2002
- true
2003
- end
2004
-
2005
- COMMAND_CAPTURING_ARG_DEFAULTS = {
2006
- in: nil,
2007
- out: nil,
2008
- err: nil,
2009
- normalize: true,
2010
- chomp: true,
2011
- merge: false,
2012
- chdir: nil,
2013
- timeout: nil, # Don't set to Git.config.timeout here since it is mutable
2014
- env: {},
2015
- raise_on_failure: true
2016
- }.freeze
2017
-
2018
- STATIC_GLOBAL_OPTS = %w[
2019
- -c core.quotePath=true
2020
- -c core.editor=false
2021
- -c color.ui=false
2022
- -c color.advice=false
2023
- -c color.diff=false
2024
- -c color.grep=false
2025
- -c color.push=false
2026
- -c color.remote=false
2027
- -c color.showBranch=false
2028
- -c color.status=false
2029
- -c color.transport=false
2030
- ].freeze
2031
-
2032
- # Runs a git command and returns the result
2033
- #
2034
- # By default, raises {Git::FailedError} if the command exits with a non-zero
2035
- # status. Pass `raise_on_failure: false` to suppress this behavior.
2036
- #
2037
- # @overload command_capturing(*args, **options_hash)
2038
- # Runs a git command and returns the result
2039
- #
2040
- # Args should exclude the 'git' command itself and global options.
2041
- # Remember to splat the arguments if given as an array.
2042
- #
2043
- # @example Run git log
2044
- # result = command_capturing('log', '--pretty=oneline')
2045
- # result.stdout #=> "abc123 First commit\ndef456 Second commit\n"
2046
- #
2047
- # @example Using an array of arguments
2048
- # args = ['log', '--pretty=oneline']
2049
- # result = command_capturing(*args)
2050
- #
2051
- # @example Suppress raising on failure
2052
- # result = command_capturing('show', 'nonexistent', raise_on_failure: false)
2053
- # result.status.success? #=> false
2054
- #
2055
- # @param args [Array<String>] the command and its arguments
2056
- #
2057
- # @param options_hash [Hash] the options to pass to the command
2058
- #
2059
- # @option options_hash [IO, nil] :in the IO object to use as stdin for the command, or nil to
2060
- # inherit the parent process stdin. Must be a real IO object with a file descriptor.
2061
- #
2062
- # @option options_hash [IO, String, #write, nil] :out the destination for captured stdout
2063
- #
2064
- # @option options_hash [IO, String, #write, nil] :err the destination for captured stderr
2065
- #
2066
- # @option options_hash [Boolean, nil] :normalize (true) normalize the output encoding to UTF-8
2067
- #
2068
- # @option options_hash [Boolean, nil] :chomp (true) remove trailing newlines from the output
2069
- #
2070
- # @option options_hash [Boolean, nil] :merge (false) merge stdout and stderr into a single output
2071
- #
2072
- # @option options_hash [String, nil] :chdir the directory to run the command in
2073
- #
2074
- # @option options_hash [Hash] :env additional environment variable overrides for this command
2075
- #
2076
- # @option options_hash [Boolean, nil] :raise_on_failure (true) whether to raise on non-zero exit
2077
- #
2078
- # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command to complete
2079
- #
2080
- # If timeout is nil, the global timeout from {Git::Config} is used.
2081
- #
2082
- # If timeout is zero, the timeout will not be enforced.
2083
- #
2084
- # If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised.
2085
- #
2086
- # If the command does not respond to SIGKILL, it will hang this method.
2087
- #
2088
- # @note Individual command classes (under {Git::Commands}) can selectively
2089
- # expose `:timeout` and `:env` to their callers by declaring them as
2090
- # execution options in their Arguments DSL definition and forwarding
2091
- # them to this method. See {Git::Commands::Clone#call} for an example
2092
- # of a command that exposes `:timeout`.
2093
- #
2094
- # @see Git::CommandLine::Capturing#run
2095
- #
2096
- # @see #command_line_capturing
2097
- #
2098
- # @return [Git::CommandLineResult] the result of the command
2099
- #
2100
- # @raise [ArgumentError] if an unknown option is passed
2101
- #
2102
- # @raise [Git::FailedError] if the command failed (when raise_on_failure is true)
2103
- #
2104
- # @raise [Git::SignaledError] if the command was signaled
2105
- #
2106
- # @raise [Git::TimeoutError] if the command times out
2107
- #
2108
- # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2109
- #
2110
- # The exception's `result` attribute is a {Git::CommandLineResult} which will
2111
- # contain the result of the command including the exit status, stdout, and
2112
- # stderr.
2113
- #
2114
- def command_capturing(*, **options_hash)
2115
- options_hash = COMMAND_CAPTURING_ARG_DEFAULTS.merge(options_hash)
2116
- options_hash[:timeout] ||= Git.config.timeout
2117
-
2118
- extra_options = options_hash.keys - COMMAND_CAPTURING_ARG_DEFAULTS.keys
2119
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2120
-
2121
- env_overrides = options_hash.delete(:env)
2122
- raise_on_failure = options_hash.delete(:raise_on_failure)
2123
- command_line_capturing.run(*, raise_on_failure: raise_on_failure, env: env_overrides, **options_hash)
2124
- end
2125
-
2126
- COMMAND_STREAMING_ARG_DEFAULTS = {
2127
- in: nil,
2128
- out: nil,
2129
- err: nil,
2130
- chdir: nil,
2131
- timeout: nil,
2132
- env: {},
2133
- raise_on_failure: true
2134
- }.freeze
2135
-
2136
- # Runs a git command using the streaming (non-capturing) execution path
2137
- #
2138
- # Unlike {#command_capturing}, stdout is NOT buffered in memory. It is
2139
- # written only to the IO object provided via the `out:` option. Stderr is
2140
- # captured internally via a StringIO for error diagnostics.
2141
- #
2142
- # Use this entry point when you want to stream large output (e.g. blob
2143
- # content from cat-file) without creating memory pressure.
2144
- #
2145
- # @overload command_streaming(*args, **options_hash)
2146
- #
2147
- # @param args [Array<String>] the git command and its arguments
2148
- #
2149
- # @param options_hash [Hash] the options to pass to the command
2150
- #
2151
- # @option options_hash [IO, nil] :in stdin IO object
2152
- #
2153
- # @option options_hash [#write, nil] :out destination for streamed stdout
2154
- #
2155
- # @option options_hash [#write, nil] :err an optional additional destination to receive stderr output
2156
- # in real time. Stderr is always captured internally; when `err:` is supplied, writes are teed
2157
- # to both the internal buffer and this destination. `result.stderr` always reflects the internal capture.
2158
- #
2159
- # @option options_hash [String, nil] :chdir the directory to run the command in
2160
- #
2161
- # @option options_hash [Hash] :env additional environment variable overrides for this command
2162
- #
2163
- # @option options_hash [Boolean, nil] :raise_on_failure (true) whether to raise on non-zero exit
2164
- #
2165
- # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command
2166
- #
2167
- # If nil, the global timeout from {Git::Config} is used.
2168
- #
2169
- # @return [Git::CommandLineResult] the result of the command
2170
- #
2171
- # `result.stdout` will always be `''` — stdout was streamed to `out:`.
2172
- # `result.stderr` contains any stderr output captured for diagnostics.
2173
- #
2174
- # @raise [ArgumentError] if an unknown option is passed
2175
- #
2176
- # @raise [Git::FailedError] if the command failed (when raise_on_failure is true)
2177
- #
2178
- # @raise [Git::SignaledError] if the command was signaled
2179
- #
2180
- # @raise [Git::TimeoutError] if the command times out
2181
- #
2182
- # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2183
- #
2184
- # @see Git::CommandLine::Streaming#run
2185
- #
2186
- # @see #command_line_streaming
2187
- #
2188
- def command_streaming(*, **options_hash)
2189
- options_hash = COMMAND_STREAMING_ARG_DEFAULTS.merge(options_hash)
2190
- options_hash[:timeout] ||= Git.config.timeout
2191
-
2192
- extra_options = options_hash.keys - COMMAND_STREAMING_ARG_DEFAULTS.keys
2193
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2194
-
2195
- env_overrides = options_hash.delete(:env)
2196
- raise_on_failure = options_hash.delete(:raise_on_failure)
2197
- command_line_streaming.run(*, raise_on_failure: raise_on_failure, env: env_overrides, **options_hash)
2198
- end
2199
-
2200
- private
2201
-
2202
- def migrate_clean_legacy_options(opts)
2203
- opts = deprecate_clean_option(opts, :ff, ':ff option is deprecated. Use force: 2 instead.')
2204
- deprecate_clean_option(opts, :force_force, ':force_force option is deprecated. Use force: 2 instead.')
2205
- end
2206
-
2207
- def deprecate_clean_option(opts, key, message)
2208
- return opts unless opts.key?(key)
2209
-
2210
- opts = opts.dup
2211
- deprecated_value = opts.delete(key)
2212
- validate_deprecated_clean_option_value!(key, deprecated_value)
2213
-
2214
- Git::Deprecation.warn(message)
2215
- return opts unless deprecated_value
2216
-
2217
- opts[:force] = merge_clean_force_option(opts[:force], force_specified: force_option_specified?(opts))
2218
- opts
2219
- end
2220
-
2221
- def force_option_specified?(opts)
2222
- opts.key?(:force) && !opts[:force].nil?
2223
- end
2224
-
2225
- def validate_deprecated_clean_option_value!(key, value)
2226
- return if value.nil? || value == true || value == false
2227
-
2228
- raise ArgumentError, "#{key} option only accepts true, false, or nil"
2229
- end
2230
-
2231
- def merge_clean_force_option(existing_force, force_specified: false)
2232
- return 2 unless force_specified
2233
-
2234
- normalized_force = normalize_clean_force_option(existing_force)
2235
-
2236
- case normalized_force
2237
- when Integer then merge_integer_clean_force_option(normalized_force)
2238
- when false
2239
- 2
2240
- else
2241
- normalized_force
2242
- end
2243
- end
2244
-
2245
- def merge_integer_clean_force_option(normalized_force)
2246
- return normalized_force if normalized_force < 1
2247
-
2248
- [normalized_force, 2].max
2249
- end
2250
-
2251
- def normalize_clean_force_option(value)
2252
- case value
2253
- when true then 1
2254
- else value
2255
- end
2256
- end
2257
-
2258
- # Build a result hash from clone options for Git::Base.new
2259
- #
2260
- # Parses the clone directory from the git command's stderr output, which
2261
- # contains either:
2262
- # Cloning into '<directory>'...
2263
- # Cloning into bare repository '<directory>'...
2264
- #
2265
- # @param command_line_result [Git::CommandLineResult] the result of the git clone command
2266
- #
2267
- # @param opts [Hash] execution context options (:log, :git_ssh)
2268
- #
2269
- # @return [Hash] result hash with directory, log, and git_ssh keys
2270
- #
2271
- def build_clone_result(command_line_result, opts)
2272
- clone_dir, bare = parse_clone_stderr(command_line_result.stderr)
2273
- result = bare ? { repository: clone_dir } : { working_directory: clone_dir }
2274
- result[:log] = opts[:log] if opts[:log]
2275
- result[:git_ssh] = opts[:git_ssh] if opts.key?(:git_ssh)
2276
- result
2277
- end
2278
-
2279
- # Parse the clone directory and bare status from git clone's stderr output
2280
- #
2281
- # Git outputs the directory in an unencoded way (no `core.quotePath` or
2282
- # similar escaping applies to clone's stderr message). The message format
2283
- # is always:
2284
- #
2285
- # Cloning into '<directory>'...
2286
- # Cloning into bare repository '<directory>'...
2287
- #
2288
- # Because the directory name is not escaped, a name containing the
2289
- # literal sequence `'...` (single-quote followed by three dots) would
2290
- # be ambiguous. In practice this is extremely unlikely.
2291
- #
2292
- # @param stderr [String] stderr output from git clone
2293
- #
2294
- # @return [Array(String, Boolean)] the clone directory and whether it's a bare repository
2295
- #
2296
- # @raise [Git::UnexpectedResultError] if the stderr output cannot be parsed
2297
- #
2298
- def parse_clone_stderr(stderr)
2299
- match = stderr.match(/Cloning into (?:(bare repository) )?'(.+)'\.\.\./)
2300
- raise Git::UnexpectedResultError, "Unable to determine clone directory from: #{stderr}" unless match
2301
-
2302
- [match[2], !match[1].nil?]
2303
- end
2304
-
2305
- # Prefixes clone result path values with the chdir directory.
2306
- #
2307
- # Mutates the given result hash in place, updating any :working_directory
2308
- # and :repository entries to be rooted under the provided +chdir+ directory.
2309
- # If +chdir+ is nil, the hash is left unchanged.
2310
- #
2311
- # @param result [Hash] clone result hash containing path information
2312
- # @param chdir [String, nil] directory under which the repository was cloned
2313
- # @return [nil]
2314
- #
2315
- def prefix_clone_result_paths!(result, chdir)
2316
- return unless chdir
2317
-
2318
- %i[working_directory repository].each do |key|
2319
- result[key] = File.join(chdir, result[key]) if result.key?(key)
2320
- end
2321
- end
2322
-
2323
- # Handles the deprecated :path option for Git::Lib#clone.
2324
- #
2325
- # If opts contains :path, emits a deprecation warning and migrates the
2326
- # value to :chdir (unless :chdir is already set). Mutates opts in place.
2327
- #
2328
- # @param opts [Hash] clone options, possibly containing :path
2329
- # @return [nil]
2330
- #
2331
- def deprecate_clone_path_option!(opts)
2332
- return unless opts.key?(:path)
2333
-
2334
- Git::Deprecation.warn('The :path option for Git::Lib#clone is deprecated, use :chdir instead')
2335
- path = opts.delete(:path)
2336
- opts[:chdir] ||= path
2337
- end
2338
-
2339
- def deprecate_clone_recursive_option!(opts)
2340
- return unless opts.key?(:recursive)
2341
-
2342
- Git::Deprecation.warn('The :recursive option for Git::Lib#clone is deprecated, use :recurse_submodules instead')
2343
- opts[:recurse_submodules] = opts.delete(:recursive)
2344
- end
2345
-
2346
- def deprecate_clone_remote_option!(opts)
2347
- return unless opts.key?(:remote)
2348
-
2349
- Git::Deprecation.warn('The :remote option for Git::Lib#clone is deprecated, use :origin instead')
2350
- opts[:origin] = opts.delete(:remote)
2351
- end
2352
-
2353
- def deprecate_clone_options!(opts)
2354
- deprecate_clone_path_option!(opts)
2355
- deprecate_clone_recursive_option!(opts)
2356
- deprecate_clone_remote_option!(opts)
2357
- end
2358
-
2359
- def deprecate_commit_add_all_option!(opts)
2360
- return unless opts.key?(:add_all)
2361
-
2362
- Git::Deprecation.warn('The :add_all option for Git::Lib#commit is deprecated, use :all instead')
2363
- opts[:all] = opts.delete(:add_all)
2364
- end
2365
-
2366
- # Extracts execution context options from clone options.
2367
- #
2368
- # @param opts [Hash] clone options
2369
- # @return [Hash] hash with :log and :git_ssh keys if present
2370
- #
2371
- def extract_clone_execution_context_opts(opts)
2372
- result = {}
2373
- result[:log] = opts.delete(:log) if opts[:log]
2374
- result[:git_ssh] = opts.delete(:git_ssh) if opts.key?(:git_ssh)
2375
- result
2376
- end
2377
-
2378
- # Translate legacy merge option names to new interface
2379
- #
2380
- # @param opts [Hash] options with possibly legacy keys
2381
- # @return [Hash] options with new keys
2382
- #
2383
- def translate_merge_options(opts)
2384
- result = opts.dup
2385
-
2386
- # :message => 'msg' becomes :m => 'msg' (git merge uses -m, not --message)
2387
- result[:m] = result.delete(:message) if result.key?(:message)
2388
-
2389
- result
2390
- end
2391
-
2392
- # Extract name-status data from --raw output lines
2393
- #
2394
- # Raw lines have the format:
2395
- # :old_mode new_mode old_sha new_sha status\tpath
2396
- # or for renames/copies:
2397
- # :old_mode new_mode old_sha new_sha Rxx\told_path\tnew_path
2398
- #
2399
- # @param output [String] raw diff output
2400
- #
2401
- # @return [Hash] mapping of file paths to status tokens
2402
- #
2403
- def extract_name_status_from_raw(output)
2404
- output.split("\n").each_with_object({}) do |line, memo|
2405
- next unless line.start_with?(':')
2406
-
2407
- parts = line[1..].split(/\s+/, 5)
2408
- status_and_paths = parts[4].split("\t")
2409
- status = status_and_paths[0]
2410
- path = status_and_paths.length > 2 ? status_and_paths[2] : status_and_paths[1]
2411
- memo[unescape_quoted_path(path)] = status
2412
- end
2413
- end
2414
-
2415
- # Extract only the patch text from combined numstat + shortstat + patch output
2416
- #
2417
- # When {Git::Commands::Diff} is called with `patch: true, numstat: true, shortstat: true`,
2418
- # the output contains numstat, shortstat, and patch sections. This method extracts
2419
- # only the patch portion (starting at "diff --git").
2420
- #
2421
- # @param output [String] combined command output
2422
- #
2423
- # @return [String] only the patch text
2424
- #
2425
- def extract_patch_text(output)
2426
- match = output.match(/^diff --git /m)
2427
- match ? output[match.begin(0)..] : output
2428
- end
2429
-
2430
- # Extract only the numstat lines from combined numstat + shortstat output
2431
- #
2432
- # When {Git::Commands::Diff} is called with `numstat: true, shortstat: true`,
2433
- # the output contains numstat lines followed by a shortstat summary line. This method
2434
- # filters out the shortstat line and empty lines, returning only the numstat lines.
2435
- #
2436
- # @param output [String] combined command output
2437
- #
2438
- # @return [Array<String>] only the numstat lines
2439
- #
2440
- def extract_numstat_lines(output)
2441
- output.split("\n").reject { |l| l.empty? || l.match?(/^\s*\d+\s+files?\s+changed/) }
2442
- end
2443
-
2444
- def build_args(opts, option_map)
2445
- Git::ArgsBuilder.new(opts, option_map).build
2446
- end
2447
-
2448
- def validate_tag_options!(opts)
2449
- needs_message = %i[a annotate s sign u local_user].any? { |k| opts[k] }
2450
- has_message = opts[:m] || opts[:message]
2451
-
2452
- return unless needs_message && !has_message
2453
-
2454
- raise ArgumentError, 'Cannot create an annotated or signed tag without a message.'
2455
- end
2456
-
2457
- def delete_tag(name)
2458
- result = Git::Commands::Tag::Delete.new(self).call(name)
2459
- raise Git::FailedError, result if result.status.exitstatus.positive?
2460
-
2461
- result.stdout
2462
- end
2463
-
2464
- def create_tag(name, target, opts)
2465
- Git::Commands::Tag::Create.new(self).call(name, target, **opts).stdout
2466
- end
2467
-
2468
- def initialize_from_base(base_object)
2469
- @git_dir = base_object.repo.to_s
2470
- @git_index_file = base_object.index&.to_s
2471
- @git_work_dir = base_object.dir&.to_s
2472
- @git_ssh = base_object.git_ssh
2473
- end
2474
-
2475
- def initialize_from_hash(base_hash)
2476
- @git_dir = base_hash[:repository]
2477
- @git_index_file = base_hash[:index]
2478
- @git_work_dir = base_hash[:working_directory]
2479
- @git_ssh = base_hash.key?(:git_ssh) ? base_hash[:git_ssh] : :use_global_config
2480
- end
2481
-
2482
- def process_commit_headers(data)
2483
- headers = { 'parent' => [] } # Pre-initialize for multiple parents
2484
- each_cat_file_header(data) do |key, value|
2485
- if key == 'parent'
2486
- headers['parent'] << value
2487
- else
2488
- headers[key] = value
2489
- end
2490
- end
2491
- headers
2492
- end
2493
-
2494
- def get_branch_state(branch_name)
2495
- Git::Commands::RevParse.new(self).call(branch_name, verify: true, quiet: true)
2496
- :active
2497
- rescue Git::FailedError => e
2498
- # An exit status of 1 with empty stderr from `rev-parse --verify`
2499
- # indicates a ref that exists but does not yet point to a commit.
2500
- raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
2501
-
2502
- :unborn
2503
- end
2504
-
2505
- def normalize_grep_opts(opts)
2506
- opts = opts.dup
2507
- opts[:pathspec] = opts.delete(:path_limiter) if opts.key?(:path_limiter)
2508
- opts
2509
- end
2510
-
2511
- def parse_grep_output(output)
2512
- Git::Parsers::Grep.parse(output)
2513
- end
2514
-
2515
- def parse_diff_stats_output(lines)
2516
- file_stats = parse_stat_lines(lines)
2517
- build_final_stats_hash(file_stats)
2518
- end
2519
-
2520
- def parse_stat_lines(lines)
2521
- lines.map do |line|
2522
- insertions_s, deletions_s, filename = split_status_line(line)
2523
- {
2524
- filename: filename,
2525
- insertions: insertions_s.to_i,
2526
- deletions: deletions_s.to_i
2527
- }
2528
- end
2529
- end
2530
-
2531
- def split_status_line(line)
2532
- parts = line.split("\t")
2533
- parts[-1] = unescape_quoted_path(parts[-1]) if parts.any?
2534
- parts
2535
- end
2536
-
2537
- def parse_raw_diff_output(stdout)
2538
- stdout.split("\n").each_with_object({}) do |line, memo|
2539
- info, file = split_status_line(line)
2540
- mode_src, mode_dest, sha_src, sha_dest, type = info.split
2541
- memo[file] = {
2542
- mode_index: mode_dest, mode_repo: mode_src.to_s[1, 7],
2543
- path: file, sha_repo: sha_src, sha_index: sha_dest,
2544
- type: type
2545
- }
2546
- end
2547
- end
2548
-
2549
- def build_final_stats_hash(file_stats)
2550
- {
2551
- total: build_total_stats(file_stats),
2552
- files: build_files_hash(file_stats)
2553
- }
2554
- end
2555
-
2556
- def build_total_stats(file_stats)
2557
- insertions = file_stats.sum { |s| s[:insertions] }
2558
- deletions = file_stats.sum { |s| s[:deletions] }
2559
- {
2560
- insertions: insertions,
2561
- deletions: deletions,
2562
- lines: insertions + deletions,
2563
- files: file_stats.size
2564
- }
2565
- end
2566
-
2567
- def build_files_hash(file_stats)
2568
- file_stats.to_h { |s| [s[:filename], s.slice(:insertions, :deletions)] }
2569
- end
2570
-
2571
- def parse_ls_remote_output(lines)
2572
- lines.each_with_object(Hash.new { |h, k| h[k] = {} }) do |line, hsh|
2573
- type, name, value = parse_ls_remote_line(line)
2574
- if name
2575
- hsh[type][name] = value
2576
- else # Handles the HEAD entry, which has no name
2577
- hsh[type].update(value)
2578
- end
2579
- end
2580
- end
2581
-
2582
- def parse_ls_remote_line(line)
2583
- sha, info = line.split("\t", 2)
2584
- ref, type, name = info.split('/', 3)
2585
-
2586
- type ||= 'head'
2587
- type = 'branches' if type == 'heads'
2588
-
2589
- value = { ref: ref, sha: sha }
2590
-
2591
- [type, name, value]
2592
- end
2593
-
2594
- # Convert a StashInfo to the legacy [index, message] format
2595
- #
2596
- # The legacy format strips the "WIP on <branch>:" or "On <branch>:" prefix
2597
- # from the message and returns only the suffix.
2598
- #
2599
- # @param info [Git::StashInfo] the stash info object
2600
- # @return [Array(Integer, String)] `[index, message]` pair with prefix stripped
2601
- #
2602
- # @api private
2603
- #
2604
- def stash_info_to_legacy(info, index = info.index)
2605
- full_message = info.message
2606
- match_data = full_message.match(/^[^:]+:(.*)$/)
2607
- message = match_data ? match_data[1] : full_message
2608
-
2609
- [index, message.strip]
2610
- end
2611
-
2612
- # Streams the staged content of a file at a given index stage to an IO object
2613
- #
2614
- # Uses the streaming execution path so content is written directly to `out_io`
2615
- # without being buffered in memory.
2616
- #
2617
- # @api private
2618
- #
2619
- # @param path [String] the path to the file in the index
2620
- #
2621
- # @param stage [Integer] the index stage to read (e.g., `1` ancestor, `2` ours, `3` theirs)
2622
- #
2623
- # @param out_io [IO] the `IO` object to stream the staged content into
2624
- #
2625
- # @return [IO] `out_io`, as passed in
2626
- #
2627
- # @raise [Git::FailedError] if the object does not exist or git exits non-zero
2628
- #
2629
- # @raise [Git::TimeoutError] if the command exceeds the configured timeout
2630
- #
2631
- def write_staged_content(path, stage, out_io)
2632
- Git::Commands::Show.new(self).call(":#{stage}:#{path}", out: out_io)
2633
- out_io
2634
- end
2635
-
2636
- def normalize_push_args(remote, branch, opts)
2637
- if branch.is_a?(Hash)
2638
- opts = branch
2639
- branch = nil
2640
- elsif remote.is_a?(Hash)
2641
- opts = remote
2642
- remote = nil
2643
- end
2644
-
2645
- opts ||= {}
2646
- # Backwards compatibility for `push(remote, branch, true)`
2647
- opts = { tags: opts } if [true, false].include?(opts)
2648
- [remote, branch, opts]
2649
- end
2650
-
2651
- def validate_push_args!(remote, branch, opts)
2652
- assert_valid_opts(opts, PUSH_ALLOWED_OPTS)
2653
- raise ArgumentError, 'remote is required if branch is specified' if !remote && branch
2654
- end
2655
-
2656
- def push_refs(remote, branch, opts)
2657
- positionals = [remote, branch].compact
2658
- Git::Commands::Push.new(self).call(*positionals, **opts.except(:tags))
2659
- end
2660
-
2661
- def push_tags_separately?(opts)
2662
- opts[:tags] && !opts[:mirror]
2663
- end
2664
-
2665
- def push_tags(remote, opts)
2666
- Git::Commands::Push.new(self).call(*[remote].compact, **opts)
2667
- end
2668
-
2669
- def temp_file_name
2670
- tempfile = Tempfile.new('archive')
2671
- file = tempfile.path
2672
- tempfile.close! # Prevents Ruby from deleting the file on garbage collection
2673
- file
2674
- end
2675
-
2676
- def parse_archive_format_options(opts)
2677
- format = opts[:format] || 'zip'
2678
- gzip = opts[:add_gzip] == true || format == 'tgz'
2679
- format = 'tar' if format == 'tgz'
2680
- [format, gzip]
2681
- end
2682
-
2683
- def apply_gzip(file)
2684
- file_content = File.read(file)
2685
- Zlib::GzipWriter.open(file) { |gz| gz.write(file_content) }
2686
- end
2687
-
2688
- # Returns a hash of environment variable overrides for git commands
2689
- #
2690
- # This method builds a hash of environment variables that control git's behavior,
2691
- # such as the git directory, working tree, and index file locations.
2692
- #
2693
- # @param additional_overrides [Hash] additional environment variables to set or unset
2694
- #
2695
- # Keys should be environment variable names (String) and values should be either:
2696
- # * A String value to set the environment variable
2697
- # * `nil` to unset the environment variable
2698
- #
2699
- # Per Process.spawn semantics, setting a key to `nil` will unset that environment
2700
- # variable, removing it from the environment passed to the git command.
2701
- #
2702
- # @return [Hash<String, String|nil>] environment variable overrides
2703
- #
2704
- # @example Basic usage with default environment variables
2705
- # env_overrides
2706
- # # => { 'GIT_DIR' => '/path/to/.git', 'GIT_WORK_TREE' => '/path/to/worktree', ... }
2707
- #
2708
- # @example Adding a custom environment variable
2709
- # env_overrides('GIT_TRACE' => '1')
2710
- # # => { 'GIT_DIR' => '/path/to/.git', ..., 'GIT_TRACE' => '1' }
2711
- #
2712
- # @example Unsetting an environment variable (used by worktree_command_line)
2713
- # env_overrides('GIT_INDEX_FILE' => nil)
2714
- # # => { 'GIT_DIR' => '/path/to/.git', 'GIT_WORK_TREE' => '/path/to/worktree',
2715
- # # 'GIT_INDEX_FILE' => nil, 'GIT_SSH' => <git_ssh_value>, 'LC_ALL' => 'en_US.UTF-8' }
2716
- # # When passed to Process.spawn, GIT_INDEX_FILE will be unset in the environment
2717
- #
2718
- # @see https://ruby-doc.org/core/Process.html#method-c-spawn Process.spawn
2719
- #
2720
- # @api private
2721
- #
2722
- def env_overrides(**additional_overrides)
2723
- {
2724
- 'GIT_DIR' => @git_dir,
2725
- 'GIT_WORK_TREE' => @git_work_dir,
2726
- 'GIT_INDEX_FILE' => @git_index_file,
2727
- 'GIT_SSH' => resolved_git_ssh,
2728
- 'GIT_EDITOR' => 'true', # Use a no-op editor so Git skips interactive editing but continues
2729
- 'LC_ALL' => 'en_US.UTF-8'
2730
- }.merge(additional_overrides)
2731
- end
2732
-
2733
- # Resolve the git_ssh value to use for this instance
2734
- #
2735
- # @return [String, nil] the resolved git_ssh value
2736
- #
2737
- # Returns the global config value if @git_ssh is the sentinel :use_global_config,
2738
- # otherwise returns @git_ssh (which may be nil or a string)
2739
- #
2740
- # @api private
2741
- #
2742
- def resolved_git_ssh
2743
- return Git::Base.config.git_ssh if @git_ssh == :use_global_config
2744
-
2745
- @git_ssh
2746
- end
2747
-
2748
- def global_opts
2749
- [].tap do |global_opts|
2750
- global_opts << "--git-dir=#{@git_dir}" unless @git_dir.nil?
2751
- global_opts << "--work-tree=#{@git_work_dir}" unless @git_work_dir.nil?
2752
- global_opts.concat(STATIC_GLOBAL_OPTS)
2753
- end
2754
- end
2755
-
2756
- # Returns the {Git::CommandLine::Capturing} instance used for capturing execution
2757
- #
2758
- # Memoized factory for the capturing execution path. Instantiates
2759
- # {Git::CommandLine::Capturing} with the current environment, binary path,
2760
- # global options, and logger.
2761
- #
2762
- # @return [Git::CommandLine::Capturing]
2763
- #
2764
- # @see Git::CommandLine::Capturing#run
2765
- #
2766
- def command_line_capturing
2767
- @command_line_capturing ||=
2768
- Git::CommandLine::Capturing.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2769
- end
2770
-
2771
- # Returns the {Git::CommandLine::Streaming} instance used for streaming execution
2772
- #
2773
- # Memoized factory for the streaming execution path. Instantiates
2774
- # {Git::CommandLine::Streaming} with the current environment, binary path,
2775
- # global options, and logger.
2776
- #
2777
- # @return [Git::CommandLine::Streaming]
2778
- #
2779
- # @see Git::CommandLine::Streaming#run
2780
- #
2781
- def command_line_streaming
2782
- @command_line_streaming ||=
2783
- Git::CommandLine::Streaming.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2784
- end
2785
-
2786
- # Validates the :count option for log commands.
2787
- #
2788
- def validate_log_count_option!(opts)
2789
- return unless opts[:count] && !opts[:count].is_a?(Integer)
2790
-
2791
- raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
2792
- end
2793
-
2794
- # Builds the positional revision range argument(s) from opts for Git::Commands::Log
2795
- #
2796
- # @param opts [Hash]
2797
- # @return [Array<String>] zero or one element array with the revision range expression
2798
- def log_revision_range_args(opts)
2799
- if opts[:between]
2800
- ["#{opts[:between][0]}..#{opts[:between][1]}"]
2801
- elsif opts[:object].is_a?(String)
2802
- [opts[:object]]
2803
- else
2804
- []
2805
- end
2806
- end
2807
-
2808
- # Builds the common keyword options for Git::Commands::Log from opts
2809
- #
2810
- # @param opts [Hash]
2811
- # @param extra [Hash] additional options to merge in (caller-specific)
2812
- # @return [Hash] keyword arguments for Git::Commands::Log#call
2813
- def log_base_call_options(opts, extra = {})
2814
- {
2815
- all: opts[:all],
2816
- cherry: opts[:cherry],
2817
- since: opts[:since],
2818
- until: opts[:until],
2819
- grep: opts[:grep],
2820
- author: opts[:author],
2821
- max_count: opts[:count],
2822
- path: opts[:path_limiter] ? Array(opts[:path_limiter]) : nil
2823
- }.merge(extra).compact
2824
- end
2825
-
2826
- def run_log_command(revision_range_args, call_opts)
2827
- log_or_empty_on_unborn do
2828
- result = Git::Commands::Log.new(self).call(
2829
- *revision_range_args,
2830
- no_color: true, pretty: 'raw',
2831
- **call_opts
2832
- )
2833
- process_commit_log_data(result.stdout.split("\n"))
2834
- end
2835
- end
2836
-
2837
- def log_or_empty_on_unborn
2838
- yield
2839
- rescue Git::FailedError => e
2840
- raise unless e.result.status.exitstatus == 128 &&
2841
- e.result.stderr =~ /does not have any commits yet/
2842
-
2843
- []
2844
- end
2845
-
2846
- def normalize_commit_tree_opts(opts, tree)
2847
- opts.dup.tap do |actual_opts|
2848
- actual_opts[:p] = actual_opts.delete(:parents) if actual_opts.key?(:parents)
2849
- actual_opts[:p] = actual_opts.delete(:parent) if actual_opts.key?(:parent)
2850
- actual_opts[:m] = actual_opts.delete(:message) if actual_opts.key?(:message)
2851
- actual_opts[:m] = "commit tree #{tree}" if actual_opts[:m].nil?
2852
- end
2853
- end
2854
- end
2855
- end