git 1.19.1 → 4.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.commitlintrc.yml +38 -0
  3. data/.github/copilot-instructions.md +2733 -0
  4. data/.github/pull_request_template.md +17 -0
  5. data/.github/workflows/continuous_integration.yml +92 -21
  6. data/.github/workflows/enforce_conventional_commits.yml +29 -0
  7. data/.github/workflows/experimental_continuous_integration.yml +59 -0
  8. data/.github/workflows/release.yml +53 -0
  9. data/.gitignore +5 -0
  10. data/.husky/commit-msg +1 -0
  11. data/.release-please-manifest.json +3 -0
  12. data/.rubocop.yml +55 -0
  13. data/.rubocop_todo.yml +12 -0
  14. data/.yardopts +4 -1
  15. data/AI_POLICY.md +24 -0
  16. data/CHANGELOG.md +501 -0
  17. data/CODE_OF_CONDUCT.md +25 -0
  18. data/CONTRIBUTING.md +323 -102
  19. data/GOVERNANCE.md +106 -0
  20. data/LICENSE +1 -1
  21. data/MAINTAINERS.md +17 -4
  22. data/README.md +575 -246
  23. data/Rakefile +13 -55
  24. data/git.gemspec +36 -30
  25. data/lib/git/args_builder.rb +111 -0
  26. data/lib/git/author.rb +9 -7
  27. data/lib/git/base.rb +602 -173
  28. data/lib/git/branch.rb +318 -38
  29. data/lib/git/branches.rb +21 -24
  30. data/lib/git/command_line.rb +330 -0
  31. data/lib/git/command_line_result.rb +9 -3
  32. data/lib/git/config.rb +10 -6
  33. data/lib/git/diff.rb +149 -81
  34. data/lib/git/diff_path_status.rb +46 -0
  35. data/lib/git/diff_stats.rb +59 -0
  36. data/lib/git/errors.rb +212 -0
  37. data/lib/git/escaped_path.rb +2 -2
  38. data/lib/git/fsck_object.rb +48 -0
  39. data/lib/git/fsck_result.rb +121 -0
  40. data/lib/git/index.rb +2 -1
  41. data/lib/git/lib.rb +1648 -643
  42. data/lib/git/log.rb +143 -106
  43. data/lib/git/object.rb +151 -125
  44. data/lib/git/path.rb +23 -16
  45. data/lib/git/remote.rb +5 -4
  46. data/lib/git/repository.rb +2 -2
  47. data/lib/git/stash.rb +11 -12
  48. data/lib/git/stashes.rb +16 -15
  49. data/lib/git/status.rb +104 -143
  50. data/lib/git/url.rb +3 -3
  51. data/lib/git/version.rb +3 -1
  52. data/lib/git/working_directory.rb +2 -0
  53. data/lib/git/worktree.rb +6 -5
  54. data/lib/git/worktrees.rb +6 -6
  55. data/lib/git.rb +131 -28
  56. data/package.json +10 -0
  57. data/redesign/1_architecture_existing.md +66 -0
  58. data/redesign/2_architecture_redesign.md +130 -0
  59. data/redesign/3_architecture_implementation.md +138 -0
  60. data/redesign/index.md +34 -0
  61. data/release-please-config.json +36 -0
  62. data/tasks/gem_tasks.rake +10 -0
  63. data/tasks/rubocop.rake +12 -0
  64. data/tasks/test.rake +13 -0
  65. data/tasks/test_gem.rake +12 -0
  66. data/tasks/yard.rake +23 -0
  67. metadata +114 -37
  68. data/.github/stale.yml +0 -25
  69. data/Dockerfile.changelog-rs +0 -12
  70. data/PULL_REQUEST_TEMPLATE.md +0 -9
  71. data/RELEASING.md +0 -70
  72. data/lib/git/base/factory.rb +0 -99
  73. data/lib/git/failed_error.rb +0 -53
  74. data/lib/git/git_execute_error.rb +0 -7
  75. data/lib/git/signaled_error.rb +0 -50
  76. /data/{ISSUE_TEMPLATE.md → .github/issue_template.md} +0 -0
data/lib/git/base.rb CHANGED
@@ -1,53 +1,76 @@
1
- require 'git/base/factory'
1
+ # frozen_string_literal: true
2
+
2
3
  require 'logger'
3
- require 'open3'
4
4
 
5
5
  module Git
6
- # Git::Base is the main public interface for interacting with Git commands.
6
+ # The main public interface for interacting with Git commands
7
7
  #
8
8
  # Instead of creating a Git::Base directly, obtain a Git::Base instance by
9
9
  # calling one of the follow {Git} class methods: {Git.open}, {Git.init},
10
10
  # {Git.clone}, or {Git.bare}.
11
11
  #
12
+ # @api public
13
+ #
12
14
  class Base
13
- include Git::Base::Factory
14
-
15
15
  # (see Git.bare)
16
16
  def self.bare(git_dir, options = {})
17
17
  normalize_paths(options, default_repository: git_dir, bare: true)
18
- self.new(options)
18
+ new(options)
19
19
  end
20
20
 
21
21
  # (see Git.clone)
22
22
  def self.clone(repository_url, directory, options = {})
23
- new_options = Git::Lib.new(nil, options[:log]).clone(repository_url, directory, options)
23
+ lib_options = {}
24
+ lib_options[:git_ssh] = options[:git_ssh] if options.key?(:git_ssh)
25
+ new_options = LibImpl.new(lib_options, options[:log]).clone(repository_url, directory, options)
24
26
  normalize_paths(new_options, bare: options[:bare] || options[:mirror])
25
27
  new(new_options)
26
28
  end
27
29
 
28
30
  # (see Git.default_branch)
29
31
  def self.repository_default_branch(repository, options = {})
30
- Git::Lib.new(nil, options[:log]).repository_default_branch(repository)
32
+ LibImpl.new(nil, options[:log]).repository_default_branch(repository)
31
33
  end
32
34
 
33
35
  # Returns (and initialize if needed) a Git::Config instance
34
36
  #
35
37
  # @return [Git::Config] the current config instance.
36
38
  def self.config
37
- @@config ||= Config.new
39
+ @config ||= Config.new
40
+ end
41
+
42
+ def self.binary_version(binary_path)
43
+ parse_version_string(execute_git_version(binary_path))
44
+ end
45
+
46
+ private_class_method def self.execute_git_version(binary_path)
47
+ bootstrap_command_line(binary_path).run('version', merge: true).stdout
48
+ rescue Git::CommandLineError => e
49
+ raise "Failed to get git version: #{e.result.status}\n#{e.result.stdout}"
50
+ rescue Errno::ENOENT, ProcessExecuter::SpawnError
51
+ raise "Failed to get git version: #{binary_path} not found"
52
+ end
53
+
54
+ private_class_method def self.parse_version_string(raw_string)
55
+ version_match = raw_string.match(/\d+(\.\d+)+/)
56
+ return [0, 0, 0] unless version_match
57
+
58
+ version_parts = version_match[0].split('.').map(&:to_i)
59
+ version_parts.fill(0, version_parts.length...3)
38
60
  end
39
61
 
40
62
  # (see Git.init)
41
63
  def self.init(directory = '.', options = {})
42
- normalize_paths(options, default_working_directory: directory, default_repository: directory, bare: options[:bare])
64
+ normalize_paths(options, default_working_directory: directory, default_repository: directory,
65
+ bare: options[:bare])
43
66
 
44
67
  init_options = {
45
- :bare => options[:bare],
46
- :initial_branch => options[:initial_branch]
68
+ bare: options[:bare],
69
+ initial_branch: options[:initial_branch]
47
70
  }
48
71
 
49
72
  directory = options[:bare] ? options[:repository] : options[:working_directory]
50
- FileUtils.mkdir_p(directory) unless File.exist?(directory)
73
+ FileUtils.mkdir_p(directory)
51
74
 
52
75
  # TODO: this dance seems awkward: this creates a Git::Lib so we can call
53
76
  # init so we can create a new Git::Base which in turn (ultimately)
@@ -59,21 +82,53 @@ module Git
59
82
  # repository you have a Git::Base instance for. This would not
60
83
  # change the existing interface (other than adding to it).
61
84
  #
62
- Git::Lib.new(options).init(init_options)
85
+ LibImpl.new(options).init(init_options)
63
86
 
64
- self.new(options)
87
+ new(options)
65
88
  end
66
89
 
67
90
  def self.root_of_worktree(working_dir)
68
- result = working_dir
69
- status = nil
91
+ raise ArgumentError, "'#{working_dir}' does not exist" unless Dir.exist?(working_dir)
92
+
93
+ execute_rev_parse_toplevel(working_dir)
94
+ end
70
95
 
71
- git_cmd = "#{Git::Base.config.binary_path} -c core.quotePath=true -c color.ui=false rev-parse --show-toplevel 2>&1"
72
- result, status = Open3.capture2(git_cmd, chdir: File.expand_path(working_dir))
73
- result = result.chomp
96
+ private_class_method def self.execute_rev_parse_toplevel(working_dir)
97
+ bootstrap_command_line(Git::Base.config.binary_path).run(
98
+ 'rev-parse', '--show-toplevel', chdir: File.expand_path(working_dir), merge: true, chomp: true
99
+ ).stdout
100
+ rescue Git::CommandLineError
101
+ raise ArgumentError, "'#{working_dir}' is not in a git working tree"
102
+ rescue Errno::ENOENT, ProcessExecuter::SpawnError
103
+ raise ArgumentError, 'Failed to find the root of the worktree: git binary not found'
104
+ end
74
105
 
75
- raise ArgumentError, "'#{working_dir}' is not in a git working tree" unless status.success?
76
- result
106
+ # A command line for the git commands that run before a repository is known
107
+ #
108
+ # `Open3` is deliberately not used here. Windows has no fork, so
109
+ # `Process.spawn` implements a redirect of the child's stdin by redirecting
110
+ # the *parent's* stdin and restoring it afterward, which replaces the
111
+ # process's stdin handle. That leaves a console REPL such as irb or pry
112
+ # unable to read input for the rest of the session. `Git::CommandLine`
113
+ # redirects only stdout and stderr, so stdin is never disturbed.
114
+ #
115
+ # @see https://github.com/ruby-git/ruby-git/issues/840 issue 840
116
+ #
117
+ # @param binary_path [String] the path to the git binary to run
118
+ #
119
+ # @return [Git::CommandLine] a command line that runs `binary_path` with
120
+ # `-c core.quotePath=true -c color.ui=false`
121
+ #
122
+ # Those are the options the `Open3` calls this replaced passed, kept as they were
123
+ # so these two commands behave exactly as before. Neither `git version` nor
124
+ # `git rev-parse --show-toplevel` emits colored output, so the remaining `color.*`
125
+ # settings in {Git::Lib}'s `STATIC_GLOBAL_OPTS` are not needed here.
126
+ #
127
+ # @api private
128
+ #
129
+ private_class_method def self.bootstrap_command_line(binary_path)
130
+ Git::CommandLine.new({}, binary_path, ['-c', 'core.quotePath=true', '-c', 'color.ui=false'],
131
+ Logger.new(nil))
77
132
  end
78
133
 
79
134
  # (see Git.open)
@@ -84,7 +139,7 @@ module Git
84
139
 
85
140
  normalize_paths(options, default_working_directory: working_dir)
86
141
 
87
- self.new(options)
142
+ new(options)
88
143
  end
89
144
 
90
145
  # Create an object that executes Git commands in the context of a working
@@ -106,20 +161,54 @@ module Git
106
161
  # commands are logged at the `:info` level. Additional logging is done
107
162
  # at the `:debug` level.
108
163
  #
164
+ # @option options [String, nil] :git_ssh Path to a custom SSH executable or script.
165
+ # Controls how SSH is configured for this {Git::Base} instance:
166
+ # - If this option is not provided, the global Git::Base.config.git_ssh setting is used.
167
+ # - If this option is explicitly set to nil, SSH is disabled for this instance.
168
+ # - If this option is a non-empty String, that value is used as the SSH command for
169
+ # this instance, overriding the global Git::Base.config.git_ssh setting.
170
+ #
109
171
  # @return [Git::Base] an object that can execute git commands in the context
110
172
  # of the opened working copy or bare repository
111
173
  #
112
174
  def initialize(options = {})
113
- if working_dir = options[:working_directory]
114
- options[:repository] ||= File.join(working_dir, '.git')
115
- options[:index] ||= File.join(options[:repository], 'index')
116
- end
117
- @logger = (options[:log] || Logger.new(nil))
118
- @logger.info("Starting Git")
175
+ options = default_paths(options)
176
+ setup_logger(options[:log])
177
+ @git_ssh = options.key?(:git_ssh) ? options[:git_ssh] : :use_global_config
178
+ initialize_components(options)
179
+ end
119
180
 
120
- @working_directory = options[:working_directory] ? Git::WorkingDirectory.new(options[:working_directory]) : nil
121
- @repository = options[:repository] ? Git::Repository.new(options[:repository]) : nil
122
- @index = options[:index] ? Git::Index.new(options[:index], false) : nil
181
+ # Update the index from the current worktree to prepare the for the next commit
182
+ #
183
+ # @example
184
+ # lib.add('path/to/file')
185
+ # lib.add(['path/to/file1','path/to/file2'])
186
+ # lib.add(all: true)
187
+ #
188
+ # @param [String, Array<String>] paths a file or files to be added to the repository (relative to the worktree root)
189
+ # @param [Hash] options
190
+ #
191
+ # @option options [Boolean] :all Add, modify, and remove index entries to match the worktree
192
+ # @option options [Boolean] :force Allow adding otherwise ignored files
193
+ #
194
+ def add(paths = '.', **options)
195
+ lib.add(paths, options)
196
+ end
197
+
198
+ # adds a new remote to this repository
199
+ # url can be a git url or a Git::Base object if it's a local reference
200
+ #
201
+ # @git.add_remote('scotts_git', 'git://repo.or.cz/rubygit.git')
202
+ # @git.fetch('scotts_git')
203
+ # @git.merge('scotts_git/master')
204
+ #
205
+ # Options:
206
+ # :fetch => true
207
+ # :track => <branch_name>
208
+ def add_remote(name, url, opts = {})
209
+ url = url.repo.to_s if url.is_a?(Git::Base)
210
+ lib.remote_add(name, url, opts)
211
+ Git::Remote.new(self, name)
123
212
  end
124
213
 
125
214
  # changes current working directory for a block
@@ -132,16 +221,16 @@ module Git
132
221
  # @git.commit('message')
133
222
  # end
134
223
  def chdir # :yields: the Git::Path
135
- Dir.chdir(dir.path) do
136
- yield dir.path
224
+ Dir.chdir(dir.to_s) do
225
+ yield dir.to_s
137
226
  end
138
227
  end
139
228
 
140
- #g.config('user.name', 'Scott Chacon') # sets value
141
- #g.config('user.email', 'email@email.com') # sets value
142
- #g.config('user.email', 'email@email.com', file: 'path/to/custom/config) # sets value in file
143
- #g.config('user.name') # returns 'Scott Chacon'
144
- #g.config # returns whole config hash
229
+ # g.config('user.name', 'Scott Chacon') # sets value
230
+ # g.config('user.email', 'email@email.com') # sets value
231
+ # g.config('user.email', 'email@email.com', file: 'path/to/custom/config) # sets value in file
232
+ # g.config('user.name') # returns 'Scott Chacon'
233
+ # g.config # returns whole config hash
145
234
  def config(name = nil, value = nil, options = {})
146
235
  if name && value
147
236
  # set value
@@ -163,9 +252,7 @@ module Git
163
252
  end
164
253
 
165
254
  # returns reference to the git index file
166
- def index
167
- @index
168
- end
255
+ attr_reader :index
169
256
 
170
257
  # returns reference to the git repository directory
171
258
  # @git.dir.path
@@ -175,50 +262,107 @@ module Git
175
262
 
176
263
  # returns the repository size in bytes
177
264
  def repo_size
178
- Dir.glob(File.join(repo.path, '**', '*'), File::FNM_DOTMATCH).reject do |f|
179
- f.include?('..')
180
- end.map do |f|
181
- File.expand_path(f)
182
- end.uniq.map do |f|
183
- File.stat(f).size.to_i
184
- end.reduce(:+)
265
+ all_files = Dir.glob(File.join(repo.path, '**', '*'), File::FNM_DOTMATCH)
266
+
267
+ all_files.reject { |file| file.include?('..') }
268
+ .map { |file| File.expand_path(file) }
269
+ .uniq
270
+ .sum { |file| File.stat(file).size.to_i }
185
271
  end
186
272
 
187
- def set_index(index_file, check = true)
273
+ def set_index(index_file, check = nil, must_exist: nil)
274
+ unless check.nil?
275
+ Git::Deprecation.warn(
276
+ 'The "check" argument is deprecated and will be removed in a future version. ' \
277
+ 'Use "must_exist:" instead.'
278
+ )
279
+ end
280
+
281
+ # default is true
282
+ must_exist = must_exist.nil? && check.nil? ? true : must_exist | check
283
+
188
284
  @lib = nil
189
- @index = Git::Index.new(index_file.to_s, check)
285
+ @index = Git::Index.new(index_file.to_s, must_exist:)
190
286
  end
191
287
 
192
- def set_working(work_dir, check = true)
288
+ def set_working(work_dir, check = nil, must_exist: nil)
289
+ unless check.nil?
290
+ Git::Deprecation.warn(
291
+ 'The "check" argument is deprecated and will be removed in a future version. ' \
292
+ 'Use "must_exist:" instead.'
293
+ )
294
+ end
295
+
296
+ # default is true
297
+ must_exist = must_exist.nil? && check.nil? ? true : must_exist | check
298
+
193
299
  @lib = nil
194
- @working_directory = Git::WorkingDirectory.new(work_dir.to_s, check)
300
+ @working_directory = Git::WorkingDirectory.new(work_dir.to_s, must_exist:)
195
301
  end
196
302
 
197
303
  # returns +true+ if the branch exists locally
198
- def is_local_branch?(branch)
199
- branch_names = self.branches.local.map {|b| b.name}
304
+ def local_branch?(branch)
305
+ branch_names = branches.local.map(&:name)
200
306
  branch_names.include?(branch)
201
307
  end
202
308
 
309
+ # @deprecated Use {#local_branch?} instead
310
+ def is_local_branch?(branch) # rubocop:disable Naming/PredicatePrefix
311
+ Git::Deprecation.warn(
312
+ 'Git::Base#is_local_branch? is deprecated and will be removed in a future version. ' \
313
+ 'Use Git::Base#local_branch? instead.'
314
+ )
315
+ local_branch?(branch)
316
+ end
317
+
203
318
  # returns +true+ if the branch exists remotely
204
- def is_remote_branch?(branch)
205
- branch_names = self.branches.remote.map {|b| b.name}
319
+ def remote_branch?(branch)
320
+ branch_names = branches.remote.map(&:name)
206
321
  branch_names.include?(branch)
207
322
  end
208
323
 
324
+ # @deprecated Use {#remote_branch?} instead
325
+ def is_remote_branch?(branch) # rubocop:disable Naming/PredicatePrefix
326
+ Git::Deprecation.warn(
327
+ 'Git::Base#is_remote_branch? is deprecated and will be removed in a future version. ' \
328
+ 'Use Git::Base#remote_branch? instead.'
329
+ )
330
+ remote_branch?(branch)
331
+ end
332
+
209
333
  # returns +true+ if the branch exists
210
- def is_branch?(branch)
211
- branch_names = self.branches.map {|b| b.name}
334
+ def branch?(branch)
335
+ branch_names = branches.map(&:name)
212
336
  branch_names.include?(branch)
213
337
  end
214
338
 
339
+ # @deprecated Use {#branch?} instead
340
+ def is_branch?(branch) # rubocop:disable Naming/PredicatePrefix
341
+ Git::Deprecation.warn(
342
+ 'Git::Base#is_branch? is deprecated and will be removed in a future version. ' \
343
+ 'Use Git::Base#branch? instead.'
344
+ )
345
+ branch?(branch)
346
+ end
347
+
215
348
  # this is a convenience method for accessing the class that wraps all the
216
349
  # actual 'git' forked system calls. At some point I hope to replace the Git::Lib
217
350
  # class with one that uses native methods or libgit C bindings
218
351
  def lib
219
- @lib ||= Git::Lib.new(self, @logger)
352
+ @lib ||= LibImpl.new(self, @logger)
220
353
  end
221
354
 
355
+ # Returns the per-instance git_ssh configuration value.
356
+ #
357
+ # This may be:
358
+ # * a [String] path when an explicit git_ssh command has been configured
359
+ # * the Symbol `:use_global_config` when this instance is using the global config
360
+ # * `nil` when SSH has been explicitly disabled for this instance
361
+ #
362
+ # @return [String, Symbol, nil] the git_ssh configuration value for this instance
363
+ # @api private
364
+ attr_reader :git_ssh
365
+
222
366
  # Run a grep for 'string' on the HEAD of the git repository
223
367
  #
224
368
  # @example Limit grep's scope by calling grep() from a specific object:
@@ -233,7 +377,8 @@ module Git
233
377
  # end
234
378
  #
235
379
  # @param string [String] the string to search for
236
- # @param path_limiter [String, Array] a path or array of paths to limit the search to or nil for no limit
380
+ # @param path_limiter [String, Pathname, Array<String, Pathname>] a path or array
381
+ # of paths to limit the search to or nil for no limit
237
382
  # @param opts [Hash] options to pass to the underlying `git grep` command
238
383
  #
239
384
  # @option opts [Boolean] :ignore_case (false) ignore case when matching
@@ -251,48 +396,32 @@ module Git
251
396
  # ```
252
397
  #
253
398
  def grep(string, path_limiter = nil, opts = {})
254
- self.object('HEAD').grep(string, path_limiter, opts)
399
+ object('HEAD').grep(string, path_limiter, opts)
255
400
  end
256
401
 
257
- # updates the repository index using the working directory content
258
- #
259
- # @example
260
- # git.add
261
- # git.add('path/to/file')
262
- # git.add(['path/to/file1','path/to/file2'])
263
- # git.add(:all => true)
264
- #
265
- # options:
266
- # :all => true
402
+ # List the files in the worktree that are ignored by git
403
+ # @return [Array<String>] the list of ignored files relative to teh root of the worktree
267
404
  #
268
- # @param [String,Array] paths files paths to be added (optional, default='.')
269
- # @param [Hash] options
270
- # @option options [boolean] :all
271
- # Update the index not only where the working tree has a file matching
272
- # <pathspec> but also where the index already has an entry.
273
- # See [the --all option to git-add](https://git-scm.com/docs/git-add#Documentation/git-add.txt--A)
274
- # for more details.
275
- #
276
- def add(paths = '.', **options)
277
- self.lib.add(paths, options)
405
+ def ignored_files
406
+ lib.ignored_files
278
407
  end
279
408
 
280
409
  # removes file(s) from the git repository
281
410
  def rm(path = '.', opts = {})
282
- self.lib.rm(path, opts)
411
+ lib.rm(path, opts)
283
412
  end
284
413
 
285
414
  alias remove rm
286
415
 
287
416
  # resets the working directory to the provided commitish
288
417
  def reset(commitish = nil, opts = {})
289
- self.lib.reset(commitish, opts)
418
+ lib.reset(commitish, opts)
290
419
  end
291
420
 
292
421
  # resets the working directory to the commitish with '--hard'
293
422
  def reset_hard(commitish = nil, opts = {})
294
- opts = {:hard => true}.merge(opts)
295
- self.lib.reset(commitish, opts)
423
+ opts = { hard: true }.merge(opts)
424
+ lib.reset(commitish, opts)
296
425
  end
297
426
 
298
427
  # cleans the working directory
@@ -303,7 +432,7 @@ module Git
303
432
  # :ff
304
433
  #
305
434
  def clean(opts = {})
306
- self.lib.clean(opts)
435
+ lib.clean(opts)
307
436
  end
308
437
 
309
438
  # returns the most recent tag that is reachable from a commit
@@ -321,8 +450,8 @@ module Git
321
450
  # :always
322
451
  # :match
323
452
  #
324
- def describe(committish=nil, opts={})
325
- self.lib.describe(committish, opts)
453
+ def describe(committish = nil, opts = {})
454
+ lib.describe(committish, opts)
326
455
  end
327
456
 
328
457
  # reverts the working directory to the provided commitish.
@@ -332,7 +461,7 @@ module Git
332
461
  # :no_edit
333
462
  #
334
463
  def revert(commitish = nil, opts = {})
335
- self.lib.revert(commitish, opts)
464
+ lib.revert(commitish, opts)
336
465
  end
337
466
 
338
467
  # commits all pending changes in the index file to the git repository
@@ -344,25 +473,25 @@ module Git
344
473
  # :author
345
474
  #
346
475
  def commit(message, opts = {})
347
- self.lib.commit(message, opts)
476
+ lib.commit(message, opts)
348
477
  end
349
478
 
350
479
  # commits all pending changes in the index file to the git repository,
351
480
  # but automatically adds all modified files without having to explicitly
352
481
  # calling @git.add() on them.
353
482
  def commit_all(message, opts = {})
354
- opts = {:add_all => true}.merge(opts)
355
- self.lib.commit(message, opts)
483
+ opts = { add_all: true }.merge(opts)
484
+ lib.commit(message, opts)
356
485
  end
357
486
 
358
487
  # checks out a branch as the new git working directory
359
- def checkout(*args, **options)
360
- self.lib.checkout(*args, **options)
488
+ def checkout(*, **)
489
+ lib.checkout(*, **)
361
490
  end
362
491
 
363
492
  # checks out an old version of a file
364
493
  def checkout_file(version, file)
365
- self.lib.checkout_file(version,file)
494
+ lib.checkout_file(version, file)
366
495
  end
367
496
 
368
497
  # fetches changes from a remote branch - this does not modify the working directory,
@@ -372,7 +501,7 @@ module Git
372
501
  opts = remote
373
502
  remote = nil
374
503
  end
375
- self.lib.fetch(remote, opts)
504
+ lib.fetch(remote, opts)
376
505
  end
377
506
 
378
507
  # Push changes to a remote repository
@@ -393,77 +522,111 @@ module Git
393
522
  # @raise [Git::FailedError] if the push fails
394
523
  # @raise [ArgumentError] if a branch is given without a remote
395
524
  #
396
- def push(*args, **options)
397
- self.lib.push(*args, **options)
525
+ def push(*, **)
526
+ lib.push(*, **)
398
527
  end
399
528
 
400
529
  # merges one or more branches into the current working branch
401
530
  #
402
531
  # you can specify more than one branch to merge by passing an array of branches
403
532
  def merge(branch, message = 'merge', opts = {})
404
- self.lib.merge(branch, message, opts)
533
+ lib.merge(branch, message, opts)
405
534
  end
406
535
 
407
536
  # iterates over the files which are unmerged
408
- def each_conflict(&block) # :yields: file, your_version, their_version
409
- self.lib.conflicts(&block)
537
+ def each_conflict(&) # :yields: file, your_version, their_version
538
+ lib.conflicts(&)
410
539
  end
411
540
 
412
- # pulls the given branch from the given remote into the current branch
541
+ # Pulls the given branch from the given remote into the current branch
542
+ #
543
+ # @param remote [String] the remote repository to pull from
544
+ # @param branch [String] the branch to pull from
545
+ # @param opts [Hash] options to pass to the pull command
546
+ #
547
+ # @option opts [Boolean] :allow_unrelated_histories (false) Merges histories of
548
+ # two projects that started their lives independently
549
+ # @example pulls from origin/master
550
+ # @git.pull
551
+ # @example pulls from upstream/master
552
+ # @git.pull('upstream')
553
+ # @example pulls from upstream/develop
554
+ # @git.pull('upstream', 'develop')
413
555
  #
414
- # @git.pull # pulls from origin/master
415
- # @git.pull('upstream') # pulls from upstream/master
416
- # @git.pull('upstream', 'develope') # pulls from upstream/develop
556
+ # @return [Void]
417
557
  #
418
- def pull(remote = nil, branch = nil)
419
- self.lib.pull(remote, branch)
558
+ # @raise [Git::FailedError] if the pull fails
559
+ # @raise [ArgumentError] if a branch is given without a remote
560
+ def pull(remote = nil, branch = nil, opts = {})
561
+ lib.pull(remote, branch, opts)
420
562
  end
421
563
 
422
564
  # returns an array of Git:Remote objects
423
565
  def remotes
424
- self.lib.remotes.map { |r| Git::Remote.new(self, r) }
566
+ lib.remotes.map { |r| Git::Remote.new(self, r) }
425
567
  end
426
568
 
427
- # adds a new remote to this repository
569
+ # sets the url for a remote
428
570
  # url can be a git url or a Git::Base object if it's a local reference
429
571
  #
430
- # @git.add_remote('scotts_git', 'git://repo.or.cz/rubygit.git')
431
- # @git.fetch('scotts_git')
432
- # @git.merge('scotts_git/master')
572
+ # @git.set_remote_url('scotts_git', 'git://repo.or.cz/rubygit.git')
433
573
  #
434
- # Options:
435
- # :fetch => true
436
- # :track => <branch_name>
437
- def add_remote(name, url, opts = {})
438
- url = url.repo.path if url.is_a?(Git::Base)
439
- self.lib.remote_add(name, url, opts)
574
+ def set_remote_url(name, url)
575
+ url = url.repo.to_s if url.is_a?(Git::Base)
576
+ lib.remote_set_url(name, url)
440
577
  Git::Remote.new(self, name)
441
578
  end
442
579
 
443
- # sets the url for a remote
444
- # url can be a git url or a Git::Base object if it's a local reference
580
+ # Configures which branches are fetched for a remote
445
581
  #
446
- # @git.set_remote_url('scotts_git', 'git://repo.or.cz/rubygit.git')
582
+ # Uses `git remote set-branches` to set or append fetch refspecs. When the `add:`
583
+ # option is not given, the `--add` option is not passed to the git command
447
584
  #
448
- def set_remote_url(name, url)
449
- url = url.repo.path if url.is_a?(Git::Base)
450
- self.lib.remote_set_url(name, url)
451
- Git::Remote.new(self, name)
585
+ # @example Replace fetched branches with a single glob pattern
586
+ # git = Git.open('/path/to/repo')
587
+ # # Only fetch branches matching "feature/*" from origin
588
+ # git.remote_set_branches('origin', 'feature/*')
589
+ #
590
+ # @example Append a glob pattern to existing fetched branches
591
+ # git = Git.open('/path/to/repo')
592
+ # # Keep existing fetch refspecs and add all release branches
593
+ # git.remote_set_branches('origin', 'release/*', add: true)
594
+ #
595
+ # @example Configure multiple explicit branches
596
+ # git = Git.open('/path/to/repo')
597
+ # git.remote_set_branches('origin', 'main', 'development', 'hotfix')
598
+ #
599
+ # @param name [String] the remote name (for example, "origin")
600
+ # @param branches [Array<String>] branch names or globs (for example, '*')
601
+ # @param add [Boolean] when true, append to existing refspecs instead of replacing them
602
+ #
603
+ # @return [nil]
604
+ #
605
+ # @raise [ArgumentError] if no branches are provided @raise [Git::FailedError] if
606
+ # the underlying git command fails
607
+ #
608
+ def remote_set_branches(name, *branches, add: false)
609
+ branch_list = branches.flatten
610
+ raise ArgumentError, 'branches are required' if branch_list.empty?
611
+
612
+ lib.remote_set_branches(name, branch_list, add: add)
613
+
614
+ nil
452
615
  end
453
616
 
454
617
  # removes a remote from this repository
455
618
  #
456
619
  # @git.remove_remote('scott_git')
457
620
  def remove_remote(name)
458
- self.lib.remote_remove(name)
621
+ lib.remote_remove(name)
459
622
  end
460
623
 
461
624
  # returns an array of all Git::Tag objects for this repository
462
625
  def tags
463
- self.lib.tags.map { |r| tag(r) }
626
+ lib.tags.map { |r| tag(r) }
464
627
  end
465
628
 
466
- # Creates a new git tag (Git::Tag)
629
+ # Create a new git tag
467
630
  #
468
631
  # @example
469
632
  # repo.add_tag('tag_name', object_reference)
@@ -482,37 +645,86 @@ module Git
482
645
  # @option options [boolean] :s Make a GPG-signed tag.
483
646
  #
484
647
  def add_tag(name, *options)
485
- self.lib.tag(name, *options)
486
- self.tag(name)
648
+ lib.tag(name, *options)
649
+ tag(name)
487
650
  end
488
651
 
489
652
  # deletes a tag
490
653
  def delete_tag(name)
491
- self.lib.tag(name, {:d => true})
654
+ lib.tag(name, { d: true })
492
655
  end
493
656
 
494
657
  # creates an archive file of the given tree-ish
495
658
  def archive(treeish, file = nil, opts = {})
496
- self.object(treeish).archive(file, opts)
659
+ object(treeish).archive(file, opts)
497
660
  end
498
661
 
499
662
  # repacks the repository
500
663
  def repack
501
- self.lib.repack
664
+ lib.repack
502
665
  end
503
666
 
504
667
  def gc
505
- self.lib.gc
668
+ lib.gc
669
+ end
670
+
671
+ # Verifies the connectivity and validity of objects in the database
672
+ #
673
+ # Runs `git fsck` to check repository integrity and identify dangling,
674
+ # missing, or unreachable objects.
675
+ #
676
+ # @overload fsck(objects = [], options = {})
677
+ # @param objects [Array<String>] specific objects to treat as heads for unreachability trace.
678
+ # If no objects are given, git fsck defaults to using the index file, all SHA-1
679
+ # references in the refs namespace, and all reflogs.
680
+ # @param [Hash] options options to pass to the underlying `git fsck` command
681
+ #
682
+ # @option options [Boolean] :unreachable print unreachable objects
683
+ # @option options [Boolean] :strict enable strict checking
684
+ # @option options [Boolean] :connectivity_only check only connectivity (faster)
685
+ # @option options [Boolean] :root report root nodes
686
+ # @option options [Boolean] :tags report tags
687
+ # @option options [Boolean] :cache consider objects in the index
688
+ # @option options [Boolean] :no_reflogs do not consider reflogs
689
+ # @option options [Boolean] :lost_found write dangling objects to .git/lost-found
690
+ # (note: this modifies the repository by creating files)
691
+ # @option options [Boolean, nil] :dangling print dangling objects (true/false/nil for default)
692
+ # @option options [Boolean, nil] :full check objects in alternate pools (true/false/nil for default)
693
+ # @option options [Boolean, nil] :name_objects name objects by refs (true/false/nil for default)
694
+ # @option options [Boolean, nil] :references check refs database consistency (true/false/nil for default)
695
+ #
696
+ # @return [Git::FsckResult] categorized objects flagged by fsck
697
+ #
698
+ # @example Check repository integrity
699
+ # result = git.fsck
700
+ # result.dangling.each { |obj| puts "#{obj.type}: #{obj.sha}" }
701
+ #
702
+ # @example Check with strict mode and suppress dangling output
703
+ # result = git.fsck(strict: true, dangling: false)
704
+ #
705
+ # @example Check if repository has any issues
706
+ # result = git.fsck
707
+ # puts "Repository is clean" if result.empty?
708
+ #
709
+ # @example List root commits
710
+ # result = git.fsck(root: true)
711
+ # result.root.each { |obj| puts obj.sha }
712
+ #
713
+ # @example Check specific objects
714
+ # result = git.fsck('abc1234', 'def5678')
715
+ #
716
+ def fsck(*objects, **opts) # rubocop:disable Style/ArgumentsForwarding
717
+ lib.fsck(*objects, **opts) # rubocop:disable Style/ArgumentsForwarding
506
718
  end
507
719
 
508
720
  def apply(file)
509
- if File.exist?(file)
510
- self.lib.apply(file)
511
- end
721
+ return unless File.exist?(file)
722
+
723
+ lib.apply(file)
512
724
  end
513
725
 
514
726
  def apply_mail(file)
515
- self.lib.apply_mail(file) if File.exist?(file)
727
+ lib.apply_mail(file) if File.exist?(file)
516
728
  end
517
729
 
518
730
  # Shows objects
@@ -520,8 +732,8 @@ module Git
520
732
  # @param [String|NilClass] objectish the target object reference (nil == HEAD)
521
733
  # @param [String|NilClass] path the path of the file to be shown
522
734
  # @return [String] the object information
523
- def show(objectish=nil, path=nil)
524
- self.lib.show(objectish, path)
735
+ def show(objectish = nil, path = nil)
736
+ lib.show(objectish, path)
525
737
  end
526
738
 
527
739
  ## LOWER LEVEL INDEX OPERATIONS ##
@@ -534,11 +746,11 @@ module Git
534
746
  return_value
535
747
  end
536
748
 
537
- def with_temp_index &blk
749
+ def with_temp_index(&)
538
750
  # Workaround for JRUBY, since they handle the TempFile path different.
539
751
  # MUST be improved to be safer and OS independent.
540
752
  if RUBY_PLATFORM == 'java'
541
- temp_path = "/tmp/temp-index-#{(0...15).map{ ('a'..'z').to_a[rand(26)] }.join}"
753
+ temp_path = "/tmp/temp-index-#{(0...15).map { ('a'..'z').to_a[rand(26)] }.join}"
542
754
  else
543
755
  tempfile = Tempfile.new('temp-index')
544
756
  temp_path = tempfile.path
@@ -546,19 +758,19 @@ module Git
546
758
  tempfile.unlink
547
759
  end
548
760
 
549
- with_index(temp_path, &blk)
761
+ with_index(temp_path, &)
550
762
  end
551
763
 
552
764
  def checkout_index(opts = {})
553
- self.lib.checkout_index(opts)
765
+ lib.checkout_index(opts)
554
766
  end
555
767
 
556
768
  def read_tree(treeish, opts = {})
557
- self.lib.read_tree(treeish, opts)
769
+ lib.read_tree(treeish, opts)
558
770
  end
559
771
 
560
772
  def write_tree
561
- self.lib.write_tree
773
+ lib.write_tree
562
774
  end
563
775
 
564
776
  def write_and_commit_tree(opts = {})
@@ -570,9 +782,8 @@ module Git
570
782
  branch(branch).update_ref(commit)
571
783
  end
572
784
 
573
-
574
- def ls_files(location=nil)
575
- self.lib.ls_files(location)
785
+ def ls_files(location = nil)
786
+ lib.ls_files(location)
576
787
  end
577
788
 
578
789
  def with_working(work_dir) # :yields: the Git::WorkingDirectory
@@ -586,41 +797,219 @@ module Git
586
797
  return_value
587
798
  end
588
799
 
589
- def with_temp_working &blk
590
- tempfile = Tempfile.new("temp-workdir")
800
+ def with_temp_working(&)
801
+ tempfile = Tempfile.new('temp-workdir')
591
802
  temp_dir = tempfile.path
592
803
  tempfile.close
593
804
  tempfile.unlink
594
- Dir.mkdir(temp_dir, 0700)
595
- with_working(temp_dir, &blk)
805
+ Dir.mkdir(temp_dir, 0o700)
806
+ with_working(temp_dir, &)
596
807
  end
597
808
 
598
809
  # runs git rev-parse to convert the objectish to a full sha
599
810
  #
600
811
  # @example
601
- # git.revparse("HEAD^^")
602
- # git.revparse('v2.4^{tree}')
603
- # git.revparse('v2.4:/doc/index.html')
812
+ # git.rev_parse("HEAD^^")
813
+ # git.rev_parse('v2.4^{tree}')
814
+ # git.rev_parse('v2.4:/doc/index.html')
604
815
  #
605
- def revparse(objectish)
606
- self.lib.revparse(objectish)
816
+ def rev_parse(objectish)
817
+ lib.rev_parse(objectish)
607
818
  end
608
819
 
609
- def ls_tree(objectish)
610
- self.lib.ls_tree(objectish)
820
+ # For backwards compatibility
821
+ alias revparse rev_parse
822
+
823
+ def ls_tree(objectish, opts = {})
824
+ lib.ls_tree(objectish, opts)
611
825
  end
612
826
 
827
+ # Returns the contents of a git object
828
+ #
829
+ # Uses `git cat-file -p` to pretty-print the contents of the given object.
830
+ #
831
+ # @param objectish [String] a SHA, branch name, tag, or other revision reference
832
+ # to the git object
833
+ #
834
+ # @return [String] the contents of the object
835
+ #
836
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
837
+ #
613
838
  def cat_file(objectish)
614
- self.lib.object_contents(objectish)
839
+ lib.cat_file_contents(objectish)
615
840
  end
616
841
 
617
- # returns the name of the branch the working directory is currently on
842
+ # The name of the branch HEAD refers to or 'HEAD' if detached
843
+ #
844
+ # Returns one of the following:
845
+ # * The branch name that HEAD refers to (even if it is an unborn branch)
846
+ # * 'HEAD' if in a detached HEAD state
847
+ #
848
+ # @return [String] the name of the branch HEAD refers to or 'HEAD' if detached
849
+ #
618
850
  def current_branch
619
- self.lib.branch_current
851
+ lib.branch_current
852
+ end
853
+
854
+ # @return [Git::Branch] an object for branch_name
855
+ def branch(branch_name = current_branch)
856
+ Git::Branch.new(self, branch_name)
857
+ end
858
+
859
+ # @return [Git::Branches] a collection of all the branches in the repository.
860
+ # Each branch is represented as a {Git::Branch}.
861
+ def branches
862
+ Git::Branches.new(self)
863
+ end
864
+
865
+ # returns a Git::Worktree object for dir, commitish
866
+ def worktree(dir, commitish = nil)
867
+ Git::Worktree.new(self, dir, commitish)
868
+ end
869
+
870
+ # returns a Git::worktrees object of all the Git::Worktrees
871
+ # objects for this repo
872
+ def worktrees
873
+ Git::Worktrees.new(self)
874
+ end
875
+
876
+ # @return [Git::Object::Commit] a commit object
877
+ def commit_tree(tree = nil, opts = {})
878
+ Git::Object::Commit.new(self, lib.commit_tree(tree, opts))
879
+ end
880
+
881
+ # @return [Git::Diff] a Git::Diff object
882
+ def diff(objectish = 'HEAD', obj2 = nil)
883
+ Git::Diff.new(self, objectish, obj2)
884
+ end
885
+
886
+ # @return [Git::Object] a Git object
887
+ def gblob(objectish)
888
+ Git::Object.new(self, objectish, 'blob')
889
+ end
890
+
891
+ # @return [Git::Object] a Git object
892
+ def gcommit(objectish)
893
+ Git::Object.new(self, objectish, 'commit')
894
+ end
895
+
896
+ # @return [Git::Object] a Git object
897
+ def gtree(objectish)
898
+ Git::Object.new(self, objectish, 'tree')
899
+ end
900
+
901
+ # @return [Git::Log] a log with the specified number of commits
902
+ def log(count = 30)
903
+ Git::Log.new(self, count)
904
+ end
905
+
906
+ # returns a Git::Object of the appropriate type
907
+ # you can also call @git.gtree('tree'), but that's
908
+ # just for readability. If you call @git.gtree('HEAD') it will
909
+ # still return a Git::Object::Commit object.
910
+ #
911
+ # object calls a method that will run a rev-parse
912
+ # on the objectish and determine the type of the object and return
913
+ # an appropriate object for that type
914
+ #
915
+ # @return [Git::Object] an instance of the appropriate type of Git::Object
916
+ def object(objectish)
917
+ Git::Object.new(self, objectish)
918
+ end
919
+
920
+ # @return [Git::Remote] a remote of the specified name
921
+ def remote(remote_name = 'origin')
922
+ Git::Remote.new(self, remote_name)
923
+ end
924
+
925
+ # @return [Git::Status] a status object
926
+ def status
927
+ Git::Status.new(self)
928
+ end
929
+
930
+ # @return [Git::Object::Tag] a tag object
931
+ def tag(tag_name)
932
+ Git::Object::Tag.new(self, tag_name)
933
+ end
934
+
935
+ # Find as good common ancestors as possible for a merge
936
+ # example: g.merge_base('master', 'some_branch', 'some_sha', octopus: true)
937
+ #
938
+ # @return [Array<Git::Object::Commit>] a collection of common ancestors
939
+ def merge_base(*)
940
+ shas = lib.merge_base(*)
941
+ shas.map { |sha| gcommit(sha) }
620
942
  end
621
943
 
944
+ # Returns a Git::Diff::Stats object for accessing diff statistics.
945
+ #
946
+ # @param objectish [String] The first commit or object to compare. Defaults to 'HEAD'.
947
+ # @param obj2 [String, nil] The second commit or object to compare.
948
+ # @param opts [Hash] Options to filter the diff.
949
+ # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter Limit stats to specified path(s).
950
+ # @return [Git::DiffStats]
951
+ def diff_stats(objectish = 'HEAD', obj2 = nil, opts = {})
952
+ Git::DiffStats.new(self, objectish, obj2, opts[:path_limiter])
953
+ end
954
+
955
+ # Returns a Git::Diff::PathStatus object for accessing the name-status report.
956
+ #
957
+ # @param objectish [String] The first commit or object to compare. Defaults to 'HEAD'.
958
+ # @param obj2 [String, nil] The second commit or object to compare.
959
+ # @param opts [Hash] Options to filter the diff.
960
+ # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter Limit status to specified path(s).
961
+ # @option opts [String, Pathname, Array<String, Pathname>] :path (deprecated) Legacy alias for :path_limiter.
962
+ # @return [Git::DiffPathStatus]
963
+ def diff_path_status(objectish = 'HEAD', obj2 = nil, opts = {})
964
+ path_limiter = if opts.key?(:path_limiter)
965
+ opts[:path_limiter]
966
+ elsif opts.key?(:path)
967
+ Git::Deprecation.warn(
968
+ 'Git::Base#diff_path_status :path option is deprecated. Use :path_limiter instead.'
969
+ )
970
+ opts[:path]
971
+ end
972
+
973
+ Git::DiffPathStatus.new(self, objectish, obj2, path_limiter)
974
+ end
975
+
976
+ # Provided for backwards compatibility
977
+ alias diff_name_status diff_path_status
978
+
622
979
  private
623
980
 
981
+ # Sets default paths in the options hash for direct `Git::Base.new` calls
982
+ #
983
+ # Factory methods like `Git.open` pre-populate these options by calling
984
+ # `normalize_paths`, making this a fallback. It avoids mutating the
985
+ # original options hash by returning a new one.
986
+ #
987
+ # @param options [Hash] the original options hash
988
+ # @return [Hash] a new options hash with defaults applied
989
+ def default_paths(options)
990
+ return options unless (working_dir = options[:working_directory])
991
+
992
+ options.dup.tap do |opts|
993
+ opts[:repository] ||= File.join(working_dir, '.git')
994
+ opts[:index] ||= File.join(opts[:repository], 'index')
995
+ end
996
+ end
997
+
998
+ # Initializes the logger from the provided options
999
+ # @param log_option [Logger, nil] The logger instance from options.
1000
+ def setup_logger(log_option)
1001
+ @logger = log_option || Logger.new(nil)
1002
+ @logger.info('Starting Git')
1003
+ end
1004
+
1005
+ # Initializes the core git objects based on the provided options
1006
+ # @param options [Hash] The processed options hash.
1007
+ def initialize_components(options)
1008
+ @working_directory = Git::WorkingDirectory.new(options[:working_directory]) if options[:working_directory]
1009
+ @repository = Git::Repository.new(options[:repository]) if options[:repository]
1010
+ @index = Git::Index.new(options[:index], must_exist: false) if options[:index]
1011
+ end
1012
+
624
1013
  # Normalize options before they are sent to Git::Base.new
625
1014
  #
626
1015
  # Updates the options parameter by setting appropriate values for the following keys:
@@ -684,18 +1073,58 @@ module Git
684
1073
  # 2. the working directory if NOT working with a bare repository
685
1074
  #
686
1075
  private_class_method def self.normalize_repository(options, default:, bare: false)
687
- repository =
688
- if bare
689
- File.expand_path(options[:repository] || default || Dir.pwd)
690
- else
691
- File.expand_path(options[:repository] || '.git', options[:working_directory])
692
- end
1076
+ initial_path = initial_repository_path(options, default: default, bare: bare)
1077
+ final_path = resolve_gitdir_if_present(initial_path, options[:working_directory])
1078
+ options[:repository] = final_path
1079
+ end
693
1080
 
694
- if File.file?(repository)
695
- repository = File.expand_path(File.open(repository).read[8..-1].strip, options[:working_directory])
1081
+ # Determines the initial, potential path to the repository directory
1082
+ #
1083
+ # This path is considered 'initial' because it is not guaranteed to be the
1084
+ # final repository location. For features like submodules or worktrees,
1085
+ # this path may point to a text file containing a `gitdir:` pointer to the
1086
+ # actual repository directory elsewhere. This initial path must be
1087
+ # subsequently resolved.
1088
+ #
1089
+ # @api private
1090
+ #
1091
+ # @param options [Hash] The options hash, checked for `[:repository]`.
1092
+ #
1093
+ # @param default [String] A fallback path if `options[:repository]` is not set.
1094
+ #
1095
+ # @param bare [Boolean] Whether the repository is bare, which changes path resolution.
1096
+ #
1097
+ # @return [String] The initial, absolute path to the `.git` directory or file.
1098
+ #
1099
+ private_class_method def self.initial_repository_path(options, default:, bare:)
1100
+ if bare
1101
+ File.expand_path(options[:repository] || default || Dir.pwd)
1102
+ else
1103
+ File.expand_path(options[:repository] || '.git', options[:working_directory])
696
1104
  end
1105
+ end
1106
+
1107
+ # Resolves the path to the actual repository if it's a `gitdir:` pointer file.
1108
+ #
1109
+ # If `path` points to a file (common in submodules and worktrees), this
1110
+ # method reads the `gitdir:` path from it and returns the real repository
1111
+ # path. Otherwise, it returns the original path.
1112
+ #
1113
+ # @api private
1114
+ #
1115
+ # @param path [String] The initial path to the repository, which may be a pointer file.
1116
+ #
1117
+ # @param working_dir [String] The working directory, used as a base to resolve the path.
1118
+ #
1119
+ # @return [String] The final, resolved absolute path to the repository directory.
1120
+ #
1121
+ private_class_method def self.resolve_gitdir_if_present(path, working_dir)
1122
+ return path unless File.file?(path)
697
1123
 
698
- options[:repository] = repository
1124
+ # The file contains `gitdir: <path>`, so we read the file,
1125
+ # extract the path part, and expand it.
1126
+ gitdir_pointer = File.read(path).sub(/\Agitdir: /, '').strip
1127
+ File.expand_path(gitdir_pointer, working_dir)
699
1128
  end
700
1129
 
701
1130
  # Normalize options[:index]