git 5.2.0 → 5.4.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.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/worktree_info'
4
+
5
+ module Git
6
+ module Parsers
7
+ # Parser for git worktree command output
8
+ #
9
+ # Handles parsing of `git worktree list --porcelain` output into structured
10
+ # data objects.
11
+ #
12
+ # @note Known limitation: git C-quotes a lock or prune reason that contains
13
+ # unusual characters such as a newline or a non-ASCII byte (see the
14
+ # `--porcelain` description in the git-worktree documentation). The reason
15
+ # is returned as git prints it, quotes and escapes included; it is not
16
+ # unquoted.
17
+ #
18
+ # ## Design Note: Namespace Organization
19
+ #
20
+ # This parser creates and returns {Git::WorktreeInfo} objects, which live at
21
+ # the top-level `Git::` namespace rather than within `Git::Parsers::`. This
22
+ # is intentional:
23
+ #
24
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
25
+ # interact with them directly
26
+ # - **Info classes are public API** - returned by commands and used throughout
27
+ # the codebase
28
+ # - **Info classes are domain entities** - represent core git concepts
29
+ # (worktrees as data)
30
+ #
31
+ # Keeping Info classes at `Git::` improves discoverability and correctly
32
+ # reflects their role as public types rather than parser internals.
33
+ #
34
+ # @api private
35
+ #
36
+ module Worktree
37
+ # Pattern splitting a porcelain line into its key and optional value
38
+ #
39
+ # The key is everything before the first space and the value is everything
40
+ # after it, so a path or reason that contains spaces is kept intact. The
41
+ # pattern matches every line; a line with no space has a nil value.
42
+ LINE_PATTERN = /\A(?<key>[^ ]*)(?: (?<value>.*))?\z/
43
+
44
+ # Attribute values for a worktree with no flags set
45
+ #
46
+ # @return [Hash{Symbol => Object}]
47
+ DEFAULT_ATTRS = {
48
+ head: nil, branch: nil, bare: false, detached: false,
49
+ locked: false, lock_reason: nil, prunable: false, prune_reason: nil
50
+ }.freeze
51
+
52
+ module_function
53
+
54
+ # Parse git worktree list --porcelain output into WorktreeInfo objects
55
+ #
56
+ # Records are separated by a blank line. Each record starts with a
57
+ # `worktree <path>` line followed by any of `HEAD <sha>`, `branch <ref>`,
58
+ # `bare`, `detached`, `locked [<reason>]`, and `prunable <reason>`.
59
+ #
60
+ # @example
61
+ # Git::Parsers::Worktree.parse_list(
62
+ # "worktree /tmp/wt/main\nHEAD f3e2c1f...\nbranch refs/heads/main\n"
63
+ # )
64
+ # # => [#<data Git::WorktreeInfo path="/tmp/wt/main", ...>]
65
+ #
66
+ # @param stdout [String] output from `git worktree list --porcelain`
67
+ #
68
+ # @return [Array<Git::WorktreeInfo>] one entry per worktree, in the order
69
+ # git listed them (the main worktree first)
70
+ #
71
+ # @raise [Git::UnexpectedResultError] if a record does not start with a
72
+ # `worktree` line or contains an unrecognized key
73
+ #
74
+ def parse_list(stdout)
75
+ records(stdout).map { |lines| parse_record(lines, stdout) }
76
+ end
77
+
78
+ # Split the output into records, each an array of chomped non-blank lines
79
+ #
80
+ # Blank lines separate records. `chunk` drops every run of lines whose
81
+ # block value is `:_separator`, so only the non-blank runs are returned.
82
+ #
83
+ # @param stdout [String] output from `git worktree list --porcelain`
84
+ #
85
+ # @return [Array<Array<String>>] the lines of each record
86
+ #
87
+ def records(stdout)
88
+ stdout.each_line(chomp: true).chunk { |line| line.empty? ? :_separator : true }.map { |_, lines| lines }
89
+ end
90
+
91
+ # Parse one record into a WorktreeInfo
92
+ #
93
+ # @param lines [Array<String>] the lines of the record
94
+ #
95
+ # @param stdout [String] the full output (for error messages)
96
+ #
97
+ # @return [Git::WorktreeInfo] the parsed entry
98
+ #
99
+ # @raise [Git::UnexpectedResultError] if the record does not start with a
100
+ # `worktree` line or contains an unrecognized key
101
+ #
102
+ def parse_record(lines, stdout)
103
+ key, path = split_line(lines.first)
104
+ unless key == 'worktree' && path
105
+ raise Git::UnexpectedResultError,
106
+ unexpected_line_error(stdout, lines.first, 'expected a record to start with "worktree <path>"')
107
+ end
108
+
109
+ Git::WorktreeInfo.new(path: path, **DEFAULT_ATTRS, **record_attrs(lines.drop(1), stdout))
110
+ end
111
+
112
+ # Collect the attributes set by the lines that follow the `worktree` line
113
+ #
114
+ # @param lines [Array<String>] the record's lines after the first
115
+ #
116
+ # @param stdout [String] the full output (for error messages)
117
+ #
118
+ # @return [Hash{Symbol => Object}] the attributes to override in DEFAULT_ATTRS
119
+ #
120
+ # @raise [Git::UnexpectedResultError] if a line has an unrecognized key
121
+ #
122
+ def record_attrs(lines, stdout)
123
+ lines.each_with_object({}) { |line, attrs| attrs.merge!(line_attrs(line, stdout)) }
124
+ end
125
+
126
+ # Map one porcelain line to the WorktreeInfo attributes it sets
127
+ #
128
+ # @param line [String] a chomped line of porcelain output
129
+ #
130
+ # @param stdout [String] the full output (for error messages)
131
+ #
132
+ # @return [Hash{Symbol => Object}] the attributes set by the line
133
+ #
134
+ # @raise [Git::UnexpectedResultError] if the line has an unrecognized key
135
+ #
136
+ def line_attrs(line, stdout)
137
+ key, value = split_line(line)
138
+
139
+ case key
140
+ when 'HEAD' then { head: value }
141
+ when 'branch' then { branch: value }
142
+ when 'bare' then { bare: true }
143
+ when 'detached' then { detached: true }
144
+ when 'locked' then { locked: true, lock_reason: value }
145
+ when 'prunable' then { prunable: true, prune_reason: value }
146
+ else raise Git::UnexpectedResultError, unexpected_line_error(stdout, line, 'unrecognized key')
147
+ end
148
+ end
149
+
150
+ # Split a porcelain line into its key and optional value
151
+ #
152
+ # @param line [String] a chomped line of porcelain output
153
+ #
154
+ # @return [Array(String, String), Array(String, nil)] the key and the
155
+ # value, or nil when the line has no value
156
+ #
157
+ def split_line(line)
158
+ match = LINE_PATTERN.match(line)
159
+ [match[:key], match[:value]]
160
+ end
161
+
162
+ # Generate the error message for a line the parser cannot handle
163
+ #
164
+ # @param stdout [String] the full output
165
+ #
166
+ # @param line [String] the problematic line
167
+ #
168
+ # @param reason [String] why the line is unexpected
169
+ #
170
+ # @return [String] the formatted error message
171
+ #
172
+ def unexpected_line_error(stdout, line, reason)
173
+ <<~ERROR
174
+ Unexpected line in output from `git worktree list --porcelain`: #{reason}
175
+
176
+ Line:
177
+ "#{line}"
178
+
179
+ Full output:
180
+ #{stdout.gsub("\n", "\n ")}
181
+ ERROR
182
+ end
183
+ end
184
+ end
185
+ end
data/lib/git/remote.rb CHANGED
@@ -7,13 +7,26 @@ module Git
7
7
  # A remote in a Git repository
8
8
  #
9
9
  # Remote objects provide access to remote metadata and operations like fetch,
10
- # merge, and remove. They should be obtained via `Git::Repository#remote`,
11
- # not constructed directly.
10
+ # merge, and remove. This class and `Git::Repository#remote`, which returns
11
+ # it, are both deprecated: read remote configuration through
12
+ # {Git::Repository::RemoteOperations#remote_list} and call the
13
+ # repository-level operations with the remote name instead.
12
14
  #
13
- # @example Getting a remote
15
+ # @example Reading a remote and fetching from it without Git::Remote
14
16
  # git = Git.open('.')
15
- # remote = git.remote('origin')
16
- # remote.fetch
17
+ # origin = git.remote_list.find { |r| r.name == 'origin' } #=> Git::RemoteInfo
18
+ # origin.url.first
19
+ # git.fetch(origin.name)
20
+ #
21
+ # @deprecated Use {Git::Repository::RemoteOperations#remote_list} and the
22
+ # repository-level remote operations instead
23
+ #
24
+ # {Git::Repository::RemoteOperations#remote_list} returns immutable
25
+ # {Git::RemoteInfo} value objects. Operations that lived on this class are
26
+ # called on the repository with the remote name instead (for example
27
+ # {Git::Repository::RemoteOperations#fetch} and
28
+ # {Git::Repository::RemoteOperations#remote_remove}). Constructing a
29
+ # `Git::Remote` emits a deprecation warning.
17
30
  #
18
31
  # @api public
19
32
  #
@@ -42,13 +55,20 @@ module Git
42
55
  #
43
56
  # @param name [String] the remote name (e.g. `'origin'`)
44
57
  #
45
- # @note Use `Git::Repository#remote` instead of constructing directly
58
+ # @note Do not construct directly. `Git::Repository#remote` is deprecated as
59
+ # well; use {Git::Repository::RemoteOperations#remote_list} and the
60
+ # repository-level remote operations instead.
46
61
  #
47
62
  # @api private
48
63
  #
49
64
  def initialize(base, name)
65
+ Git::Deprecation.warn(
66
+ 'Git::Remote is deprecated and will be removed in v6.0.0. ' \
67
+ 'Use Git::Repository#remote_list and the repository-level remote operations instead.'
68
+ )
50
69
  @base = base
51
- config = remote_repository.config_remote(name)
70
+ # config_remote is deprecated too; silence it so one Git::Remote.new emits one warning
71
+ config = Git::Deprecation.silence { remote_repository.config_remote(name) }
52
72
  @name = name
53
73
  @url = config['url']
54
74
  @fetch_opts = config['fetch']
@@ -119,6 +139,16 @@ module Git
119
139
  #
120
140
  # @return [Git::Branch] a branch object representing `<remote>/<branch>`
121
141
  #
142
+ # @deprecated Use
143
+ # `Git::Repository#branch_list("#{name}/#{branch || current_branch}").first`
144
+ # instead
145
+ #
146
+ # With no argument this method falls back to the current branch, so the
147
+ # replacement has to supply `Git::Repository#current_branch` itself. The
148
+ # replacement returns a {Git::BranchInfo} value object rather than a
149
+ # {Git::Branch}, and returns `nil` when the remote-tracking branch does
150
+ # not exist.
151
+ #
122
152
  def branch(branch = nil)
123
153
  branch ||= remote_repository.current_branch
124
154
  Git::Branch.new(@base, "#{@name}/#{branch}")
@@ -6,7 +6,11 @@ module Git
6
6
  # Each instance holds the parsed configuration for a single remote as read
7
7
  # from the repository's git config. Multi-value fields (`:url`, `:push_url`,
8
8
  # `:fetch`, `:push`) are always `Array<String>` (never `nil`; may be empty).
9
- # All other fields are nilable except `:name`.
9
+ # Those arrays are frozen copies of the values given, so the set of URLs and
10
+ # refspecs cannot change after construction; use `with` to derive a modified
11
+ # copy. The immutability is shallow, as with any `Data` member: the strings
12
+ # inside those arrays and the scalar members are the objects the caller
13
+ # passed in, not copies. All other fields are nilable except `:name`.
10
14
  #
11
15
  # @example Minimal remote (fetch-only, one URL)
12
16
  # info = Git::RemoteInfo.new(
@@ -88,13 +92,13 @@ module Git
88
92
  #
89
93
  # @param name [String] the name of the remote (required)
90
94
  #
91
- # @param url [Array<String>] fetch URLs (default `[]`)
95
+ # @param url [Array<String>] fetch URLs (default `[]`); stored as a frozen copy
92
96
  #
93
- # @param push_url [Array<String>] push URLs (default `[]`)
97
+ # @param push_url [Array<String>] push URLs (default `[]`); stored as a frozen copy
94
98
  #
95
- # @param fetch [Array<String>] fetch refspecs (default `[]`)
99
+ # @param fetch [Array<String>] fetch refspecs (default `[]`); stored as a frozen copy
96
100
  #
97
- # @param push [Array<String>] push refspecs (default `[]`)
101
+ # @param push [Array<String>] push refspecs (default `[]`); stored as a frozen copy
98
102
  #
99
103
  # @param mirror [Boolean, nil] mirror flag (default `nil`)
100
104
  #
@@ -118,7 +122,7 @@ module Git
118
122
  #
119
123
  # @return [Git::RemoteInfo]
120
124
  #
121
- def initialize(
125
+ def initialize( # rubocop:disable Metrics/ParameterLists
122
126
  name:,
123
127
  url: [],
124
128
  push_url: [],
@@ -136,11 +140,64 @@ module Git
136
140
  vcs: nil
137
141
  )
138
142
  super(
139
- name:, url: Array(url), push_url: Array(push_url), fetch: Array(fetch),
140
- push: Array(push), mirror:, skip_default_update:, tag_opt:, prune:,
141
- prune_tags:, receivepack:, uploadpack:, promisor:, partial_clone_filter:,
142
- vcs:
143
+ name:, url: Array(url).dup.freeze, push_url: Array(push_url).dup.freeze,
144
+ fetch: Array(fetch).dup.freeze, push: Array(push).dup.freeze, mirror:,
145
+ skip_default_update:, tag_opt:, prune:, prune_tags:, receivepack:, uploadpack:,
146
+ promisor:, partial_clone_filter:, vcs:
143
147
  )
144
148
  end
149
+
150
+ # Return a copy of this RemoteInfo with the given fields replaced
151
+ #
152
+ # Routes through {#initialize} so the multi-value fields of the copy are
153
+ # frozen copies, the same as on construction. `Data#with` bypasses
154
+ # `initialize` on Ruby 3.2, which would leave those arrays mutable.
155
+ #
156
+ # @example Replace the fetch URL
157
+ # info.with(url: ['https://example.com/other.git']).url
158
+ # # => ["https://example.com/other.git"]
159
+ #
160
+ # @param fields [Hash{Symbol => Object}] the fields to replace, keyed by
161
+ # member name
162
+ #
163
+ # @option fields [String] :name the name of the remote
164
+ #
165
+ # @option fields [Array<String>] :url fetch URLs
166
+ #
167
+ # @option fields [Array<String>] :push_url push URLs
168
+ #
169
+ # @option fields [Array<String>] :fetch fetch refspecs
170
+ #
171
+ # @option fields [Array<String>] :push push refspecs
172
+ #
173
+ # @option fields [Boolean, nil] :mirror mirror flag
174
+ #
175
+ # @option fields [Boolean, nil] :skip_default_update skip-default-update flag
176
+ #
177
+ # @option fields [String, nil] :tag_opt tag-fetching option
178
+ #
179
+ # @option fields [Boolean, nil] :prune prune flag
180
+ #
181
+ # @option fields [Boolean, nil] :prune_tags prune-tags flag
182
+ #
183
+ # @option fields [String, nil] :receivepack receive-pack path
184
+ #
185
+ # @option fields [String, nil] :uploadpack upload-pack path
186
+ #
187
+ # @option fields [Boolean, nil] :promisor promisor flag
188
+ #
189
+ # @option fields [String, nil] :partial_clone_filter partial-clone filter
190
+ #
191
+ # @option fields [String, nil] :vcs VCS type
192
+ #
193
+ # @return [Git::RemoteInfo] a new instance; `self` when no fields are given
194
+ #
195
+ # @raise [ArgumentError] if a key is not a member of this Data class
196
+ #
197
+ def with(**fields)
198
+ return self if fields.empty?
199
+
200
+ self.class.new(**to_h, **fields)
201
+ end
145
202
  end
146
203
  end
@@ -187,6 +187,73 @@ module Git
187
187
  Git::Commands::Checkout::Branch.new(@execution_context).call(target, **translated_opts).stdout
188
188
  end
189
189
 
190
+ # Run a block with the given branch checked out, then restore the original branch
191
+ #
192
+ # Records the current branch (or the current commit when HEAD is detached),
193
+ # checks out `branch`, and yields to the block. If the block returns a truthy
194
+ # value, all pending changes are committed with `message` (see
195
+ # {#commit_all}); if it returns a falsy value, the index and working tree are
196
+ # hard-reset instead (see {#reset}). The original branch or commit is then
197
+ # checked out again. The hard reset discards changes to tracked files only;
198
+ # untracked files created by the block are left in place.
199
+ #
200
+ # Unlike `Git::Branch#in_branch`, this method does not create `branch`. The
201
+ # branch must be an existing local branch. Unlike {#checkout}, a commit SHA,
202
+ # tag, or remote-tracking branch is rejected before any checkout happens:
203
+ # those detach HEAD, and a commit made there would be left dangling once the
204
+ # original branch is restored. HEAD must
205
+ # be on a branch with at least one commit, or detached: an unborn branch (no
206
+ # commits yet) cannot be checked out again by name, so it is rejected before
207
+ # any checkout happens.
208
+ #
209
+ # **Note:** the restore checkout is not wrapped in `ensure`. If the block,
210
+ # the commit, or the reset raises an exception, the repository is left
211
+ # checked out on `branch` rather than restored to the original branch.
212
+ #
213
+ # @example Commit a new file on a feature branch
214
+ # repo.in_branch('feature', 'Add README') do
215
+ # File.write('README.md', '# Hello')
216
+ # repo.add('README.md')
217
+ # true # commit and return to the original branch
218
+ # end
219
+ #
220
+ # @example Discard experimental changes to a tracked file
221
+ # repo.in_branch('scratch') do
222
+ # File.write('README.md', '# Try something')
223
+ # false # hard-reset and return to the original branch
224
+ # end
225
+ #
226
+ # @param branch [String] the name of an existing local branch to check out
227
+ #
228
+ # @param message [String] the commit message used when the block returns a
229
+ # truthy value
230
+ #
231
+ # @return [String] git's stdout from the final checkout back to the original
232
+ # branch or commit
233
+ #
234
+ # @raise [ArgumentError] if `branch` is not an existing local branch
235
+ #
236
+ # @raise [Git::Error] if HEAD is on an unborn branch
237
+ #
238
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
239
+ #
240
+ # @yield executes the block with `branch` checked out
241
+ #
242
+ # @yieldreturn [Object] a truthy value to commit all changes, a falsy value to
243
+ # hard-reset
244
+ #
245
+ def in_branch(branch, message = 'in branch work')
246
+ SharedPrivate.assert_local_branch!(self, branch)
247
+ restore_point = SharedPrivate.head_restore_point(self)
248
+ checkout(branch)
249
+ if yield
250
+ commit_all(message)
251
+ else
252
+ reset(nil, hard: true)
253
+ end
254
+ checkout(restore_point)
255
+ end
256
+
190
257
  # Populate the working tree from the index
191
258
  #
192
259
  # @example Check out all files from the index
@@ -630,7 +697,31 @@ module Git
630
697
  #
631
698
  # @raise [Git::FailedError] if git exits with a non-zero exit status
632
699
  #
700
+ # @deprecated Use `branch_list(name).first` and the name-based branch
701
+ # operations instead
702
+ #
703
+ # {#branch_list} returns immutable {Git::BranchInfo} value objects
704
+ # rather than {Git::Branch}. It takes `git branch --list` patterns, so
705
+ # pass the short name of a local branch or `"#{remote}/#{name}"` for a
706
+ # remote-tracking branch; the `remotes/` and `refs/` prefixes this
707
+ # method accepts match nothing. A `"#{remote}/#{name}"` pattern also
708
+ # matches a local branch of that name, so take `find(&:remote?)` rather
709
+ # than `first` for a remote-tracking branch. With no argument this
710
+ # method wraps {#current_branch}, which is `'HEAD'` when HEAD is
711
+ # detached; {#branch_list} has no entry for a detached or unborn HEAD,
712
+ # so use {#current_branch_state} in those states. Call the
713
+ # corresponding {Git::Repository} method (e.g. {#checkout},
714
+ # {#branch_new}, {#branch_delete}) for operations on a branch.
715
+ #
716
+ # @see #branch_list
717
+ #
633
718
  def branch(branch_name = current_branch)
719
+ Git::Deprecation.warn(
720
+ 'Git::Repository#branch is deprecated and will be removed in v6.0.0. ' \
721
+ 'Use Git::Repository#branch_list(name).first for a local branch, ' \
722
+ 'Git::Repository#branch_list("remote/name").find(&:remote?) for a remote-tracking branch, ' \
723
+ 'and the name-based branch operations instead.'
724
+ )
634
725
  Git::Branch.new(self, branch_name)
635
726
  end
636
727
 
@@ -657,7 +748,21 @@ module Git
657
748
  #
658
749
  # @raise [Git::FailedError] if git exits with a non-zero exit status
659
750
  #
751
+ # @deprecated Use {#branch_list} instead
752
+ #
753
+ # {#branch_list} returns `Array<Git::BranchInfo>` (immutable value
754
+ # objects) rather than a {Git::Branches} collection. Filter it with
755
+ # `select(&:remote?)` or `reject(&:remote?)` in place of
756
+ # `branches.remote` and `branches.local`, and look a branch up by name
757
+ # with `branch_list(name).first` in place of `branches[name]`.
758
+ #
759
+ # @see #branch_list
760
+ #
660
761
  def branches
762
+ Git::Deprecation.warn(
763
+ 'Git::Repository#branches is deprecated and will be removed in v6.0.0. ' \
764
+ 'Use Git::Repository#branch_list instead.'
765
+ )
661
766
  Git::Branches.new(self)
662
767
  end
663
768
 
@@ -677,6 +782,11 @@ module Git
677
782
  # @param execution_context [Git::ExecutionContext::Repository] the
678
783
  # execution context for git commands
679
784
  #
785
+ # The full `refs/heads/<name>` ref is verified rather than the bare name.
786
+ # A bare name follows the gitrevisions search order, in which
787
+ # `refs/tags/<name>` is tried before `refs/heads/<name>`, so an unborn
788
+ # branch that shares its name with a tag would be reported as `:active`.
789
+ #
680
790
  # @param branch_name [String] the branch name to verify
681
791
  #
682
792
  # @return [:active, :unborn] the branch ref state
@@ -687,7 +797,7 @@ module Git
687
797
  # @api private
688
798
  #
689
799
  def get_branch_state(execution_context, branch_name)
690
- Git::Commands::RevParse.new(execution_context).call(branch_name, verify: true, quiet: true)
800
+ Git::Commands::RevParse.new(execution_context).call("refs/heads/#{branch_name}", verify: true, quiet: true)
691
801
  :active
692
802
  rescue Git::FailedError => e
693
803
  raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
@@ -10,8 +10,8 @@ require 'git/repository/shared_private'
10
10
 
11
11
  module Git
12
12
  class Repository
13
- # Facade methods for merge operations: merging branches into the current branch,
14
- # and finding common ancestors between commits
13
+ # Facade methods for merge operations: merging branches into the current branch
14
+ # or into another branch, and finding common ancestors between commits
15
15
  #
16
16
  # Included by {Git::Repository}.
17
17
  #
@@ -97,6 +97,100 @@ module Git
97
97
  Git::Commands::Merge::Start.new(@execution_context).call(*branches, no_edit: true, **opts).stdout
98
98
  end
99
99
 
100
+ # Option keys accepted by {#merge_into}
101
+ #
102
+ # The keys accepted by {#merge} except `:no_commit`. A merge stopped before
103
+ # its commit leaves `target_branch` unchanged, and the restore checkout would
104
+ # carry the staged merge result onto the original branch instead.
105
+ MERGE_INTO_ALLOWED_OPTS = %i[no_ff m message].freeze
106
+ private_constant :MERGE_INTO_ALLOWED_OPTS
107
+
108
+ # Merge one or more branches into another branch without leaving the current branch
109
+ #
110
+ # Records the current branch (or the current commit when HEAD is detached),
111
+ # checks out `target_branch`, merges `branch` into it with {#merge}, then
112
+ # checks out the original branch or commit again. Use {#merge} directly when
113
+ # the target is the currently checked-out branch.
114
+ #
115
+ # `target_branch` must be an existing local branch. Unlike {#checkout}, a
116
+ # commit SHA, tag, or remote-tracking branch is rejected before any checkout
117
+ # happens: those detach HEAD, and the merge commit made there would be left
118
+ # dangling once the original branch is restored while the named ref stayed
119
+ # unchanged.
120
+ #
121
+ # HEAD must be on a branch with at least one commit, or detached: an unborn
122
+ # branch (no commits yet) cannot be checked out again by name, so it is
123
+ # rejected before any checkout happens.
124
+ #
125
+ # Option keys, the source list, and `target_branch` are checked before any
126
+ # branch is checked out, so those failures never leave the repository on
127
+ # `target_branch`. Anything rejected later, such as an option value that is
128
+ # not accepted or a source ref that does not exist, surfaces inside {#merge}
129
+ # after the checkout and follows the Note below.
130
+ #
131
+ # The `:no_commit` option is not accepted: a merge stopped before its commit
132
+ # would leave `target_branch` unchanged and the restore checkout would carry
133
+ # the staged result onto the original branch. To merge without committing,
134
+ # call {#checkout} and {#merge} directly.
135
+ #
136
+ # **Note:** the restore checkout is not wrapped in `ensure`. If the merge
137
+ # fails (for example, on a conflict), the repository is left checked out on
138
+ # `target_branch` with the merge in progress rather than restored to the
139
+ # original branch.
140
+ #
141
+ # @example Merge a feature branch into main while staying on the current branch
142
+ # repo.merge_into('main', 'feature')
143
+ #
144
+ # @example Merge with a no-fast-forward commit message
145
+ # repo.merge_into('main', 'feature', 'Merge feature into main', no_ff: true)
146
+ #
147
+ # @example Octopus merge of multiple branches into main
148
+ # repo.merge_into('main', %w[feature-a feature-b])
149
+ #
150
+ # @param target_branch [String] the name of an existing local branch to
151
+ # merge into
152
+ #
153
+ # @param branch [#to_s, Array<#to_s>] the branch or branches to merge into
154
+ # `target_branch`; accepts the same forms as {#merge}, but must name at
155
+ # least one branch
156
+ #
157
+ # @param message [String, nil] optional commit message for the merge commit;
158
+ # see {#merge} for how it interacts with the `:message` and `:m` options
159
+ #
160
+ # @param opts [Hash] additional options forwarded to {#merge}
161
+ #
162
+ # @option opts [Boolean, nil] :no_ff (nil) create a merge commit even when
163
+ # fast-forward is possible (`--no-ff`)
164
+ #
165
+ # @option opts [String] :message (nil) commit message; prefer the `:m` option
166
+ #
167
+ # @option opts [String] :m (nil) commit message (`-m` flag)
168
+ #
169
+ # @return [String] git's stdout from the merge command
170
+ #
171
+ # @raise [ArgumentError] when unsupported options (including `:no_commit`)
172
+ # are provided
173
+ #
174
+ # @raise [ArgumentError] when `branch` is `nil` or an empty Array
175
+ #
176
+ # @raise [ArgumentError] when `target_branch` is not an existing local branch
177
+ #
178
+ # @raise [Git::Error] when HEAD is on an unborn branch
179
+ #
180
+ # @raise [Git::FailedError] when git exits with a non-zero exit status
181
+ #
182
+ def merge_into(target_branch, branch, message = nil, opts = {})
183
+ SharedPrivate.assert_valid_opts!(MERGE_INTO_ALLOWED_OPTS, **opts)
184
+ raise ArgumentError, 'at least one branch to merge is required' if Array(branch).empty?
185
+
186
+ SharedPrivate.assert_local_branch!(self, target_branch)
187
+ restore_point = SharedPrivate.head_restore_point(self)
188
+ checkout(target_branch)
189
+ output = merge(branch, message, opts)
190
+ checkout(restore_point)
191
+ output
192
+ end
193
+
100
194
  # Find common ancestor commit(s) for use in a merge
101
195
  #
102
196
  # @example Find the common ancestor of two branches