git 5.6.0 → 6.0.0

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -16
  3. data/CONTRIBUTING.md +44 -31
  4. data/README.md +135 -67
  5. data/UPGRADING.md +915 -268
  6. data/git.gemspec +6 -8
  7. data/lib/git/author_info.rb +1 -2
  8. data/lib/git/commands/base.rb +22 -2
  9. data/lib/git/commands/cat_file/raw.rb +0 -68
  10. data/lib/git/commands/maintenance/register.rb +0 -4
  11. data/lib/git/commands/maintenance/run.rb +0 -4
  12. data/lib/git/commands/maintenance/start.rb +0 -4
  13. data/lib/git/commands/maintenance/stop.rb +0 -4
  14. data/lib/git/commands/maintenance/unregister.rb +0 -4
  15. data/lib/git/commands/maintenance.rb +0 -2
  16. data/lib/git/commands/show_ref/exclude_existing.rb +1 -1
  17. data/lib/git/commands/show_ref/exists.rb +0 -5
  18. data/lib/git/commands/show_ref/list.rb +1 -1
  19. data/lib/git/commands/show_ref/verify.rb +1 -1
  20. data/lib/git/commands/show_ref.rb +1 -1
  21. data/lib/git/commands/worktree/repair.rb +0 -5
  22. data/lib/git/diff_path_status.rb +2 -2
  23. data/lib/git/encoding_utils.rb +25 -0
  24. data/lib/git/errors.rb +23 -5
  25. data/lib/git/factories.rb +361 -125
  26. data/lib/git/log.rb +3 -75
  27. data/lib/git/object.rb +4 -185
  28. data/lib/git/path_resolver.rb +104 -36
  29. data/lib/git/repository/branching.rb +21 -189
  30. data/lib/git/repository/committing.rb +0 -25
  31. data/lib/git/repository/context_helpers.rb +249 -155
  32. data/lib/git/repository/diffing.rb +2 -44
  33. data/lib/git/repository/merging.rb +32 -54
  34. data/lib/git/repository/object_operations.rb +121 -338
  35. data/lib/git/repository/remote_operations.rb +7 -219
  36. data/lib/git/repository/staging.rb +6 -184
  37. data/lib/git/repository/stashing.rb +126 -130
  38. data/lib/git/repository/status_operations.rb +1 -54
  39. data/lib/git/repository/worktree_operations.rb +31 -118
  40. data/lib/git/repository.rb +20 -299
  41. data/lib/git/system_call_guard.rb +95 -0
  42. data/lib/git/version.rb +1 -1
  43. data/lib/git.rb +47 -364
  44. metadata +25 -19
  45. data/lib/git/author.rb +0 -46
  46. data/lib/git/branch.rb +0 -693
  47. data/lib/git/branches.rb +0 -194
  48. data/lib/git/remote.rb +0 -191
  49. data/lib/git/stash.rb +0 -124
  50. data/lib/git/stashes.rb +0 -198
  51. data/lib/git/status.rb +0 -384
  52. data/lib/git/worktree.rb +0 -180
  53. data/lib/git/worktrees.rb +0 -152
data/lib/git/factories.rb CHANGED
@@ -18,6 +18,33 @@ module Git
18
18
  # @api private
19
19
  #
20
20
  module Factories # rubocop:disable Metrics/ModuleLength
21
+ # Option keys {Git.open} accepts
22
+ #
23
+ # @return [Array<Symbol>]
24
+ #
25
+ # @api private
26
+ #
27
+ OPEN_ALLOWED_OPTS = %i[repository index log git_ssh binary_path].freeze
28
+ private_constant :OPEN_ALLOWED_OPTS
29
+
30
+ # Option keys {Git.bare} accepts
31
+ #
32
+ # @return [Array<Symbol>]
33
+ #
34
+ # @api private
35
+ #
36
+ BARE_ALLOWED_OPTS = %i[index log git_ssh binary_path].freeze
37
+ private_constant :BARE_ALLOWED_OPTS
38
+
39
+ # Option keys {Git.init} accepts
40
+ #
41
+ # @return [Array<Symbol>]
42
+ #
43
+ # @api private
44
+ #
45
+ INIT_ALLOWED_OPTS = %i[bare initial_branch repository separate_git_dir index log git_ssh binary_path].freeze
46
+ private_constant :INIT_ALLOWED_OPTS
47
+
21
48
  # Clone a repository into a new directory
22
49
  #
23
50
  # @example Clone into the default directory
@@ -36,6 +63,11 @@ module Git
36
63
  # @param directory [String, Pathname, nil] the local directory name to clone into;
37
64
  # git derives the name from the URL when `nil`
38
65
  #
66
+ # Expanded before git runs, so `~` is the home directory and a relative
67
+ # path is relative to `:chdir` when given and to the process working
68
+ # directory otherwise. git creates the expanded directory, and the
69
+ # returned repository is bound to it.
70
+ #
39
71
  # @param options [Hash] options that control cloning
40
72
  #
41
73
  # Some options configure the returned {Git::Repository} instance after
@@ -79,7 +111,14 @@ module Git
79
111
  #
80
112
  # @option options [Boolean, nil] :dissociate stop borrowing from references
81
113
  #
82
- # @option options [String, nil] :separate_git_dir alternate git directory path
114
+ # @option options [String, nil] :repository alternate git directory path
115
+ #
116
+ # Writes a gitfile in the working tree. Alias: `:separate_git_dir`.
117
+ # Expanded before git runs, like `directory`: `~` is the home directory
118
+ # and a relative path is relative to `:chdir` when given and to the
119
+ # process working directory otherwise.
120
+ #
121
+ # @option options [String, nil] :separate_git_dir alias for `:repository`
83
122
  #
84
123
  # @option options [String, Array<String>, nil] :server_option
85
124
  # protocol-v2 server options
@@ -153,32 +192,47 @@ module Git
153
192
  #
154
193
  # @option options [String, nil] :index a non-standard path to the index file
155
194
  #
195
+ # Expanded before git runs, like `directory`: a relative path is expanded
196
+ # against `:chdir` when given, and against the process working directory
197
+ # otherwise.
198
+ #
156
199
  # @option options [String, Pathname, nil] :chdir run `git clone` from within
157
200
  # this directory
158
201
  #
159
- # @option options [String, Pathname, nil] :path deprecated; use `:chdir` instead
160
- #
161
- # @option options [Boolean, nil] :recursive deprecated; use
162
- # `:recurse_submodules` instead
163
- #
164
- # @option options [String, nil] :remote deprecated; use `:origin` instead
202
+ # Expanded before git runs, so `~` is the home directory and a relative
203
+ # path is relative to the process working directory. `directory`,
204
+ # `:repository`, and `:index` are expanded against the expanded value.
165
205
  #
166
206
  # @return [Git::Repository] a repository bound to the cloned working copy or
167
207
  # bare repository
168
208
  #
169
- # @raise [ArgumentError] if unsupported options are provided
209
+ # @raise [ArgumentError] if unsupported options are provided, or if
210
+ # `directory`, `:repository`, `:index`, or `:chdir` begins with `~user`
211
+ # for a user that does not exist
170
212
  #
171
213
  # @raise [Git::FailedError] if git exits with a non-zero exit status
172
214
  #
215
+ # @raise [Git::Error] if a filesystem call made while resolving the paths
216
+ # fails
217
+ #
173
218
  # @raise [Git::UnexpectedResultError] if the cloned directory cannot be
174
219
  # determined from git's output
175
220
  #
221
+ # @note If git fails before the transfer completes, git removes `directory`
222
+ # and the separate git directory it created, or empties `directory` when
223
+ # it existed before the call. A `:repository` path that already exists is
224
+ # refused before the transfer starts. If the transfer completes but the
225
+ # checkout fails, or the `:timeout` or the global timeout kills git, what
226
+ # git wrote is left in place. If {Git::UnexpectedResultError} is raised,
227
+ # the completed clone is left in place.
228
+ #
176
229
  # @api public
177
230
  #
178
231
  def clone(repository_url, directory = nil, options = {})
179
232
  opts, context_opts = prepare_clone_options(options)
233
+ directory = expand_directory(directory, bare: context_opts[:bare], base: opts[:chdir]) if directory
180
234
  clone_result = run_clone_command(repository_url, directory, opts, context_opts)
181
- paths = resolve_paths_from_clone_result(clone_result, opts, context_opts)
235
+ paths = resolve_paths_from_clone_result(clone_result, directory, opts, context_opts)
182
236
 
183
237
  from_paths(clone_repository_options(context_opts), paths)
184
238
  end
@@ -196,6 +250,10 @@ module Git
196
250
  #
197
251
  # @param directory [String] the directory to initialize; defaults to `'.'`
198
252
  #
253
+ # Expanded before git runs, so `~` is the home directory and a relative
254
+ # path is relative to the process working directory. git creates the
255
+ # expanded directory, and the returned repository is bound to it.
256
+ #
199
257
  # @param options [Hash] options that control initialization
200
258
  #
201
259
  # Some options configure the returned {Git::Repository} instance after
@@ -208,6 +266,8 @@ module Git
208
266
  # @option options [String, nil] :repository path for the `.git` directory
209
267
  #
210
268
  # Writes a gitfile in the working tree. Alias: `:separate_git_dir`.
269
+ # Expanded before git runs, like `directory`: `~` is the home directory
270
+ # and a relative path is relative to the process working directory.
211
271
  #
212
272
  # @option options [String, nil] :separate_git_dir alias for `:repository`
213
273
  #
@@ -228,19 +288,33 @@ module Git
228
288
  # @option options [String, nil] :index custom index path for the returned
229
289
  # repository
230
290
  #
231
- # Ignored when `:bare` is `true`.
291
+ # Expanded before git runs, like `directory`: `~` is the home directory
292
+ # and a relative path is relative to the process working directory.
232
293
  #
233
294
  # @return [Git::Repository] a repository bound to the newly initialized repository
234
295
  #
296
+ # @raise [ArgumentError] if `options` contains an unknown key
297
+ #
298
+ # @raise [ArgumentError] if `directory`, `:repository`, or `:index` begins
299
+ # with `~user` for a user that does not exist
300
+ #
235
301
  # @raise [Git::FailedError] if git exits with a non-zero exit status
236
302
  #
303
+ # @raise [Git::Error] if a filesystem call made while resolving the paths
304
+ # fails
305
+ #
306
+ # @note If git exits non-zero after it starts writing, whatever it wrote is
307
+ # left in place: `directory` when git created it, and a git directory
308
+ # (`.git`, or the `:repository` path and the gitfile pointing at it) that
309
+ # git may not recognize as a repository. If git succeeds but the
310
+ # repository cannot be opened, the initialized repository is left in place.
311
+ #
237
312
  # @api public
238
313
  #
239
314
  def init(directory = '.', options = {})
240
- options = options.dup
241
- if options.key?(:separate_git_dir) && options[:repository].nil?
242
- options[:repository] = options.delete(:separate_git_dir)
243
- end
315
+ reject_unknown_option_keys!(options.keys, INIT_ALLOWED_OPTS)
316
+ options = normalize_init_options(options)
317
+ directory = expand_directory(directory, bare: options[:bare])
244
318
 
245
319
  run_init_command(directory, options)
246
320
  open_after_init(directory, options)
@@ -254,6 +328,8 @@ module Git
254
328
  # @param working_dir [String] the path to the root of the working copy
255
329
  #
256
330
  # May be any path inside the working tree when `:repository` is not given.
331
+ # Expanded before use: `~` is the home directory and a relative path is
332
+ # relative to the process working directory.
257
333
  #
258
334
  # @param options [Hash] options that control how the repository is located
259
335
  #
@@ -261,10 +337,13 @@ module Git
261
337
  # `.git` directory
262
338
  #
263
339
  # When given, `working_dir` is used as-is (the working tree root is not
264
- # auto-detected).
340
+ # auto-detected). A relative path is expanded against the process working
341
+ # directory, not against `working_dir`.
265
342
  #
266
343
  # @option options [String, nil] :index a non-standard path to the index file
267
344
  #
345
+ # A relative path is expanded against the process working directory.
346
+ #
268
347
  # @option options [Logger, nil] :log logger used for git operations
269
348
  #
270
349
  # @option options [String, nil, :use_global_config] :git_ssh
@@ -281,8 +360,15 @@ module Git
281
360
  #
282
361
  # @return [Git::Repository] a repository bound to the resolved paths
283
362
  #
363
+ # @raise [ArgumentError] if `options` contains an unknown key
364
+ #
284
365
  # @raise [ArgumentError] if `working_dir` is not a directory or is not inside
285
- # a git working tree
366
+ # a git working tree, or if it begins with `~user` for a user that does not
367
+ # exist
368
+ #
369
+ # @raise [Git::Error] if the `.git` path is a gitdir pointer file that cannot
370
+ # be read, if the git binary cannot be found or fails to launch, or if a
371
+ # filesystem call made while resolving the paths fails
286
372
  #
287
373
  # @note This method opens working copies only. To open a bare repository, use
288
374
  # `Git.bare`.
@@ -290,14 +376,14 @@ module Git
290
376
  # @api public
291
377
  #
292
378
  def open(working_dir, options = {})
293
- raise ArgumentError, "'#{working_dir}' is not a directory" unless Dir.exist?(working_dir)
379
+ reject_unknown_option_keys!(options.keys, OPEN_ALLOWED_OPTS)
380
+ expanded_dir = PathResolver.expand_path(working_dir, PathResolver::WORKING_DIRECTORY_MESSAGE)
381
+ raise ArgumentError, "'#{working_dir}' is not a directory" unless Dir.exist?(expanded_dir)
294
382
 
295
- working_dir = resolve_open_working_dir(working_dir, options) unless options[:repository]
383
+ working_dir = options[:repository] ? expanded_dir : resolve_open_working_dir(expanded_dir, options)
296
384
 
297
- paths = PathResolver.resolve_paths(
298
- working_directory: working_dir,
299
- repository: options[:repository],
300
- index: options[:index]
385
+ paths = resolve_repository_paths(
386
+ working_dir, bare: false, repository: options[:repository], index: options[:index]
301
387
  )
302
388
 
303
389
  from_paths(options, paths)
@@ -308,10 +394,19 @@ module Git
308
394
  # @example Open a bare repository
309
395
  # repository = Git.bare('/path/to/repo.git')
310
396
  #
397
+ # @example Bind a bare repository to a scratch index
398
+ # repository = Git.bare('/srv/repo.git', index: '/tmp/scratch.index')
399
+ # repository.read_tree('HEAD')
400
+ # tree_sha = repository.write_tree
401
+ #
311
402
  # @param git_dir [String] the path to the bare repository directory
312
403
  #
313
404
  # @param options [Hash] options used to configure the repository instance
314
405
  #
406
+ # @option options [String, nil] :index a non-standard path to the index file
407
+ #
408
+ # A relative path is expanded against the process working directory.
409
+ #
315
410
  # @option options [Logger, nil] :log logger used for git operations
316
411
  #
317
412
  # @option options [String, nil, :use_global_config] :git_ssh
@@ -328,10 +423,15 @@ module Git
328
423
  #
329
424
  # @return [Git::Repository] a repository bound to the bare repository directory
330
425
  #
426
+ # @raise [ArgumentError] if `options` contains an unknown key
427
+ #
428
+ # @raise [Git::Error] if `git_dir` is a gitdir pointer file that cannot be read
429
+ #
331
430
  # @api public
332
431
  #
333
432
  def bare(git_dir, options = {})
334
- paths = PathResolver.resolve_paths(repository: git_dir, bare: true)
433
+ reject_unknown_option_keys!(options.keys, BARE_ALLOWED_OPTS)
434
+ paths = resolve_repository_paths(git_dir, bare: true, index: options[:index])
335
435
 
336
436
  from_paths(options, paths)
337
437
  end
@@ -380,8 +480,15 @@ module Git
380
480
 
381
481
  # Resolve repository paths from a completed clone result
382
482
  #
483
+ # When the caller gave a directory, it was expanded before git ran and is
484
+ # used as is. Only when git derived the name from the URL is the directory
485
+ # read from git's output, where it is a relative name under `:chdir`.
486
+ #
383
487
  # @param clone_result [Git::CommandLine::Result] the completed clone result
384
488
  #
489
+ # @param directory [String, nil] the expanded directory given to {.clone},
490
+ # or `nil` when git derived the name from the URL
491
+ #
385
492
  # @param opts [Hash] command-ready clone options
386
493
  #
387
494
  # @param context_opts [Hash] context options produced while preparing clone
@@ -390,26 +497,75 @@ module Git
390
497
  # @option opts [String, Pathname, nil] :chdir run `git clone` from within
391
498
  # this directory
392
499
  #
393
- # @option opts [Boolean, nil] :bare clone as a bare repository
394
- #
395
- # @option opts [Boolean, nil] :mirror set up a mirror of the source
500
+ # @option context_opts [Boolean] :bare whether `:bare` or `:mirror` was
501
+ # requested
396
502
  #
397
- # @option context_opts [String, nil] :index custom index path for the
398
- # returned repository
503
+ # @option context_opts [String, nil] :index the already expanded custom
504
+ # index path for the returned repository
399
505
  #
400
506
  # @return [Hash{Symbol => (String, nil)}] resolved path hash
401
507
  #
402
- # @raise [Git::UnexpectedResultError] if the clone directory cannot be parsed
508
+ # @raise [Git::UnexpectedResultError] if no directory was given and the clone
509
+ # directory cannot be parsed from git's output
510
+ #
511
+ # @api private
512
+ #
513
+ def resolve_paths_from_clone_result(clone_result, directory, opts, context_opts)
514
+ clone_dir = directory || prefix_with_chdir(parse_clone_directory(clone_result.stderr), opts[:chdir])
515
+
516
+ bare = context_opts[:bare] || bare_clone?(clone_result.stderr)
517
+ resolve_repository_paths(clone_dir, bare: bare, index: context_opts[:index])
518
+ end
519
+
520
+ # Expand the directory given to {.init} or {.clone} before git sees it
521
+ #
522
+ # git does not expand `~` in a path on its command line, so the expansion
523
+ # happens here. The directory git creates is then the one the returned
524
+ # repository is bound to.
525
+ #
526
+ # @param directory [String, Pathname] the directory to initialize or clone
527
+ # into
528
+ #
529
+ # @param bare [Boolean, nil] whether the directory is the bare repository
530
+ # itself rather than a working directory; selects the error message
531
+ #
532
+ # @param base [String, Pathname, nil] the directory a relative path is
533
+ # expanded against, or `nil` for the process working directory
534
+ #
535
+ # @return [String] the absolute path
536
+ #
537
+ # @raise [ArgumentError] if the path begins with `~user` for a user that
538
+ # does not exist
539
+ #
540
+ # @raise [Git::Error] if the path cannot be expanded, which happens when the
541
+ # process working directory has been removed
542
+ #
543
+ # @api private
544
+ #
545
+ def expand_directory(directory, bare:, base: nil)
546
+ message = bare ? PathResolver::REPOSITORY_MESSAGE : PathResolver::WORKING_DIRECTORY_MESSAGE
547
+ PathResolver.expand_path(directory, message, base: base)
548
+ end
549
+
550
+ # Join the clone directory git reported onto the `:chdir` it ran in
551
+ #
552
+ # Used only when no directory was given to {.clone}: git derives a relative
553
+ # name from the URL and creates it under `chdir`. An absolute path, should
554
+ # git ever report one, passes through unchanged.
555
+ #
556
+ # @param path [String] the clone directory reported by `git clone`
557
+ #
558
+ # @param chdir [String, Pathname, nil] the directory git ran in, or `nil`
559
+ #
560
+ # @return [String] `path` prefixed with `chdir` when `chdir` is given and
561
+ # `path` is relative; otherwise `path` unchanged
403
562
  #
404
563
  # @api private
405
564
  #
406
- def resolve_paths_from_clone_result(clone_result, opts, context_opts)
407
- clone_dir, cloned_bare = parse_clone_stderr(clone_result.stderr)
408
- chdir = opts[:chdir]
409
- clone_dir = File.join(chdir, clone_dir) if chdir && !Pathname.new(clone_dir).absolute?
565
+ def prefix_with_chdir(path, chdir)
566
+ return path if chdir.nil? || Pathname.new(path).absolute?
410
567
 
411
- bare = opts[:bare] || opts[:mirror] || cloned_bare
412
- resolve_clone_paths(clone_dir, bare, context_opts[:index])
568
+ File.join(chdir, path)
413
569
  end
414
570
 
415
571
  # Build repository construction options from clone context options
@@ -473,12 +629,32 @@ module Git
473
629
  #
474
630
  # @raise [ArgumentError] if `working_dir` is not inside a git working tree
475
631
  #
632
+ # @raise [Git::Error] if the git binary cannot be found or fails to launch, or
633
+ # if `working_dir` cannot be expanded to an absolute path
634
+ #
476
635
  # @api private
477
636
  #
478
637
  def resolve_open_working_dir(working_dir, options)
479
638
  PathResolver.root_of_worktree(working_dir, **context_defaults(options))
480
639
  end
481
640
 
641
+ # Raise if `keys` contains a key outside `allowed`
642
+ #
643
+ # @param keys [Array<Symbol>] the keys of the caller-supplied options
644
+ #
645
+ # @param allowed [Array<Symbol>] the keys the factory accepts
646
+ #
647
+ # @return [void]
648
+ #
649
+ # @raise [ArgumentError] if `keys` contains an unknown key
650
+ #
651
+ # @api private
652
+ #
653
+ def reject_unknown_option_keys!(keys, allowed)
654
+ unknown = keys - allowed
655
+ raise ArgumentError, "Unknown options: #{unknown.join(', ')}" unless unknown.empty?
656
+ end
657
+
482
658
  # Build a repository from caller options and resolved paths
483
659
  #
484
660
  # @param options [Hash] the caller-supplied options (`:git_ssh`,
@@ -523,15 +699,45 @@ module Git
523
699
  #
524
700
  def prepare_clone_options(options)
525
701
  opts = options.dup
526
- deprecate_clone_path_option!(opts)
527
- deprecate_clone_recursive_option!(opts)
528
- deprecate_clone_remote_option!(opts)
529
702
  context_opts = extract_clone_context_options!(opts)
703
+ expand_path_option!(opts, :chdir, PathResolver::CHDIR_MESSAGE)
530
704
  normalize_clone_repository_option!(opts)
705
+ expand_path_option!(context_opts, :index, PathResolver::INDEX_MESSAGE, base: opts[:chdir])
531
706
 
532
707
  [opts, context_opts]
533
708
  end
534
709
 
710
+ # Expand a path option in place when it is present
711
+ #
712
+ # git does no tilde expansion and ruby-git starts it without a shell, so
713
+ # every path option is expanded here before git sees it. A path that cannot
714
+ # be expanded is rejected before git runs.
715
+ #
716
+ # @param hash [Hash] the options hash (mutated in place)
717
+ #
718
+ # @param key [Symbol] the option to expand; left alone when `nil` or absent
719
+ #
720
+ # @param message [String] the {Git::Error} message when expansion fails
721
+ #
722
+ # @param base [String, Pathname, nil] the directory a relative path is
723
+ # expanded against, or `nil` for the process working directory
724
+ #
725
+ # @return [void] mutates `hash` in place
726
+ #
727
+ # @raise [ArgumentError] if the path begins with `~user` for a user that
728
+ # does not exist
729
+ #
730
+ # @raise [Git::Error] if the path cannot be expanded, which happens when the
731
+ # process working directory has been removed
732
+ #
733
+ # @api private
734
+ #
735
+ def expand_path_option!(hash, key, message, base: nil)
736
+ return unless hash[key]
737
+
738
+ hash[key] = PathResolver.expand_path(hash[key], message, base: base)
739
+ end
740
+
535
741
  # Extract clone context options from command-ready options
536
742
  #
537
743
  # @param opts [Hash] clone options (mutated in place)
@@ -547,7 +753,14 @@ module Git
547
753
  # @option opts [String, nil] :index custom index path for the returned
548
754
  # repository
549
755
  #
550
- # @return [Hash{Symbol => Object}] context options for clone setup
756
+ # @option opts [Boolean, nil] :bare clone as a bare repository; left in
757
+ # `opts` for git
758
+ #
759
+ # @option opts [Boolean, nil] :mirror set up a mirror of the source; left in
760
+ # `opts` for git
761
+ #
762
+ # @return [Hash{Symbol => Object}] context options for clone setup, with
763
+ # `:bare` true when either `:bare` or `:mirror` was requested
551
764
  #
552
765
  # @api private
553
766
  #
@@ -556,47 +769,127 @@ module Git
556
769
  logger: opts.delete(:log),
557
770
  git_ssh: opts.key?(:git_ssh) ? opts.delete(:git_ssh) : :use_global_config,
558
771
  binary_path: opts.key?(:binary_path) ? opts.delete(:binary_path) : :use_global_config,
559
- index: opts.delete(:index)
772
+ index: opts.delete(:index),
773
+ bare: opts[:bare] || opts[:mirror] || false
560
774
  }
561
775
  end
562
776
 
563
777
  # Normalize the clone repository option for `git clone`
564
778
  #
779
+ # Resolves the `:separate_git_dir` alias to `:repository`, then renames
780
+ # `:repository` to `:separate_git_dir` and expands it before git runs,
781
+ # against `:chdir` when given, like the clone directory. git does no tilde
782
+ # expansion, so a `~`-prefixed path handed to git would create a directory
783
+ # literally named `~`.
784
+ #
565
785
  # @param opts [Hash] clone options (mutated in place)
566
786
  #
567
787
  # @option opts [String, nil] :repository alternate git directory path
568
788
  #
789
+ # @option opts [String, nil] :separate_git_dir alias for `:repository`
790
+ #
791
+ # @option opts [String, Pathname, nil] :chdir the directory git runs in
792
+ #
569
793
  # @return [void] mutates `opts` in place
570
794
  #
795
+ # @raise [ArgumentError] if `:repository` begins with `~user` for a user
796
+ # that does not exist
797
+ #
798
+ # @raise [Git::Error] if the path cannot be expanded, which happens when the
799
+ # process working directory has been removed
800
+ #
571
801
  # @api private
572
802
  #
573
803
  def normalize_clone_repository_option!(opts)
574
- return unless opts.key?(:repository)
575
-
804
+ resolve_repository_alias!(opts)
576
805
  repository_val = opts.delete(:repository)
577
- opts[:separate_git_dir] = repository_val if repository_val
806
+ return unless repository_val
807
+
808
+ opts[:separate_git_dir] =
809
+ PathResolver.expand_path(repository_val, PathResolver::REPOSITORY_MESSAGE, base: opts[:chdir])
578
810
  end
579
811
 
580
- # Resolve paths for the cloned repository
812
+ # Resolve the repository, working directory, and index paths for a repository
581
813
  #
582
- # @param clone_dir [String] the directory reported by `git clone`
814
+ # @param dir [String] the bare git directory, or the working directory of a
815
+ # non-bare repository
583
816
  #
584
- # @param bare [Boolean] whether the clone is bare
817
+ # @param bare [Boolean] whether the repository is bare
585
818
  #
586
819
  # @param index [String, nil] optional custom index path
587
820
  #
821
+ # @param repository [String, nil] optional custom `.git` directory path;
822
+ # ignored when `bare` is `true`
823
+ #
588
824
  # @return [Hash{Symbol => (String, nil)}] resolved path hash
589
825
  #
590
826
  # @api private
591
827
  #
592
- def resolve_clone_paths(clone_dir, bare, index)
593
- args = bare ? { repository: clone_dir, bare: true } : { working_directory: clone_dir }
828
+ def resolve_repository_paths(dir, bare:, index:, repository: nil)
829
+ args = bare ? { repository: dir, bare: true } : { working_directory: dir, repository: repository }
594
830
  PathResolver.resolve_paths(**args, index: index)
595
831
  end
596
832
 
833
+ # Normalize the options given to {.init}
834
+ #
835
+ # Resolves the `:separate_git_dir` alias to `:repository` and expands
836
+ # `:repository` and `:index` before git runs, so the git directory git
837
+ # creates is the one the returned repository is bound to and a path that
838
+ # cannot be expanded is rejected before a repository is left on disk.
839
+ #
840
+ # @param options [Hash] the caller-supplied options hash
841
+ #
842
+ # @option options [String, nil] :repository path for the `.git` directory
843
+ #
844
+ # @option options [String, nil] :separate_git_dir alias for `:repository`
845
+ #
846
+ # @option options [String, nil] :index custom index path for the returned
847
+ # repository
848
+ #
849
+ # @return [Hash] a new options hash with `:repository` set and expanded when
850
+ # either spelling was given, and `:index` expanded when given
851
+ #
852
+ # @raise [ArgumentError] if `:repository` or `:index` begins with `~user`
853
+ # for a user that does not exist
854
+ #
855
+ # @raise [Git::Error] if a path cannot be expanded, which happens when the
856
+ # process working directory has been removed
857
+ #
858
+ # @api private
859
+ #
860
+ def normalize_init_options(options)
861
+ options = options.dup
862
+ resolve_repository_alias!(options)
863
+ expand_path_option!(options, :repository, PathResolver::REPOSITORY_MESSAGE)
864
+ expand_path_option!(options, :index, PathResolver::INDEX_MESSAGE)
865
+ options
866
+ end
867
+
868
+ # Resolve the `:separate_git_dir` alias onto `:repository`
869
+ #
870
+ # `:separate_git_dir` is removed. Its value becomes `:repository` unless
871
+ # `:repository` was given a non-nil value, which wins.
872
+ #
873
+ # @param options [Hash] factory options (mutated in place)
874
+ #
875
+ # @option options [String, nil] :repository path for the git directory
876
+ #
877
+ # @option options [String, nil] :separate_git_dir alias for `:repository`
878
+ #
879
+ # @return [void] mutates `options` in place
880
+ #
881
+ # @api private
882
+ #
883
+ def resolve_repository_alias!(options)
884
+ return unless options.key?(:separate_git_dir)
885
+
886
+ separate_git_dir = options.delete(:separate_git_dir)
887
+ options[:repository] = separate_git_dir if options[:repository].nil?
888
+ end
889
+
597
890
  # Run the `git init` command using a global execution context
598
891
  #
599
- # @param directory [String] the directory to initialize
892
+ # @param directory [String] the absolute path of the directory to initialize
600
893
  #
601
894
  # @param options [Hash] the normalized options hash (after alias resolution)
602
895
  #
@@ -674,6 +967,9 @@ module Git
674
967
  #
675
968
  # @option options [Logger, nil] :log logger used for git operations
676
969
  #
970
+ # @option options [String, nil] :index custom index path for the returned
971
+ # repository
972
+ #
677
973
  # @return [Hash{Symbol => Object}] options accepted by {.open} and {.bare}
678
974
  #
679
975
  # @api private
@@ -684,6 +980,7 @@ module Git
684
980
  binary_path: options.fetch(:binary_path, :use_global_config)
685
981
  }.tap do |open_opts|
686
982
  open_opts[:log] = options[:log] if options[:log]
983
+ open_opts[:index] = options[:index] if options[:index]
687
984
  end
688
985
  end
689
986
 
@@ -710,104 +1007,43 @@ module Git
710
1007
  #
711
1008
  def worktree_open_options_after_init(options)
712
1009
  base_open_options_after_init(options).tap do |open_opts|
713
- open_opts[:index] = options[:index] if options[:index]
714
1010
  open_opts[:repository] = options[:repository] if options[:repository]
715
1011
  end
716
1012
  end
717
1013
 
718
- # Parse the clone directory and bare status from `git clone` stderr output
1014
+ # Parse the directory git derived from the URL out of `git clone` stderr
1015
+ #
1016
+ # Only called when no directory was given to {.clone}. The name git derives
1017
+ # is a single path component, so it never spans a line.
719
1018
  #
720
1019
  # @param stderr [String] stderr output from `git clone`
721
1020
  #
722
- # @return [Array] a two-element tuple `[clone_dir, bare]`
1021
+ # @return [String] the clone directory git reported
723
1022
  #
724
1023
  # @raise [Git::UnexpectedResultError] if the stderr output cannot be parsed
725
1024
  #
726
1025
  # @api private
727
1026
  #
728
- def parse_clone_stderr(stderr)
729
- match = stderr.match(/Cloning into (?:(bare repository) )?'(.+)'\.\.\./)
1027
+ def parse_clone_directory(stderr)
1028
+ match = stderr.match(/^Cloning into (?:bare repository )?'(.+)'\.\.\.$/)
730
1029
  raise Git::UnexpectedResultError, "Unable to determine clone directory from: #{stderr}" unless match
731
1030
 
732
- [match[2], !match[1].nil?]
1031
+ match[1]
733
1032
  end
734
1033
 
735
- # Handle the deprecated `:path` option for {clone}
736
- #
737
- # @param opts [Hash] clone options (mutated in place)
738
- #
739
- # @option opts [String, Pathname, nil] :path deprecated; use `:chdir`
740
- # instead
1034
+ # Whether `git clone` reported that it created a bare repository
741
1035
  #
742
- # @option opts [String, Pathname, nil] :chdir run `git clone` from within
743
- # this directory
744
- #
745
- # @return [void] mutates `opts` in place
746
- #
747
- # @api private
748
- #
749
- def deprecate_clone_path_option!(opts)
750
- return unless opts.key?(:path)
751
-
752
- if defined?(Git::Deprecation)
753
- Git::Deprecation.warn(
754
- 'The :path option for Git.clone is deprecated and will be removed in v6.0.0. ' \
755
- 'Use :chdir instead.'
756
- )
757
- end
758
- path = opts.delete(:path)
759
- opts[:chdir] ||= path
760
- end
761
-
762
- # Handle the deprecated `:recursive` option for {clone}
1036
+ # Matches only the fixed prefix of git's progress line, so a destination
1037
+ # that spans several lines does not affect the result.
763
1038
  #
764
- # @param opts [Hash] clone options (mutated in place)
765
- #
766
- # @option opts [Boolean, nil] :recursive deprecated; use
767
- # `:recurse_submodules` instead
768
- #
769
- # @option opts [Boolean, String, Array<String>, nil] :recurse_submodules
770
- # initialize submodules after cloning
771
- #
772
- # @return [void] mutates `opts` in place
773
- #
774
- # @api private
775
- #
776
- def deprecate_clone_recursive_option!(opts)
777
- return unless opts.key?(:recursive)
778
-
779
- if defined?(Git::Deprecation)
780
- Git::Deprecation.warn(
781
- 'The :recursive option for Git.clone is deprecated and will be removed in v6.0.0. ' \
782
- 'Use :recurse_submodules instead.'
783
- )
784
- end
785
- opts[:recurse_submodules] = opts.delete(:recursive)
786
- end
787
-
788
- # Handle the deprecated `:remote` option for {clone}
789
- #
790
- # @param opts [Hash] clone options (mutated in place)
791
- #
792
- # @option opts [String, nil] :remote deprecated; use `:origin` instead
793
- #
794
- # @option opts [String, nil] :origin remote name to use instead of
795
- # `origin`
1039
+ # @param stderr [String] stderr output from `git clone`
796
1040
  #
797
- # @return [void] mutates `opts` in place
1041
+ # @return [Boolean] true when git reported a bare repository
798
1042
  #
799
1043
  # @api private
800
1044
  #
801
- def deprecate_clone_remote_option!(opts)
802
- return unless opts.key?(:remote)
803
-
804
- if defined?(Git::Deprecation)
805
- Git::Deprecation.warn(
806
- 'The :remote option for Git.clone is deprecated and will be removed in v6.0.0. ' \
807
- 'Use :origin instead.'
808
- )
809
- end
810
- opts[:origin] = opts.delete(:remote)
1045
+ def bare_clone?(stderr)
1046
+ stderr.match?(/^Cloning into bare repository '/)
811
1047
  end
812
1048
  end
813
1049
  end