git 5.2.0 → 5.3.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.
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
5
+ require 'git/author_info'
3
6
  require 'git/stash_info'
4
7
 
5
8
  module Git
@@ -45,10 +48,10 @@ module Git
45
48
  # %gs = reflog subject (the stash message)
46
49
  # %an = author name
47
50
  # %ae = author email
48
- # %aI = author date (ISO 8601 format)
51
+ # %aI = author date (ISO 8601 format, parsed into a Time)
49
52
  # %cn = committer name
50
53
  # %ce = committer email
51
- # %cI = committer date (ISO 8601 format)
54
+ # %cI = committer date (ISO 8601 format, parsed into a Time)
52
55
  STASH_FORMAT = [
53
56
  '%H', # 0: full SHA
54
57
  '%h', # 1: short SHA
@@ -163,7 +166,7 @@ module Git
163
166
  # @return [Hash] attributes for StashInfo.new
164
167
  #
165
168
  def stash_info_attrs(parts, index)
166
- core_attrs(parts, index).merge(author_attrs(parts)).merge(committer_attrs(parts))
169
+ core_attrs(parts, index).merge(author: author_info(parts), committer: committer_info(parts))
167
170
  end
168
171
 
169
172
  # Build core StashInfo attributes from parsed fields
@@ -182,30 +185,60 @@ module Git
182
185
  }
183
186
  end
184
187
 
185
- # Build author-related StashInfo attributes from parsed fields
188
+ # Build the author identity from the parsed fields
186
189
  #
187
190
  # @param parts [Array<String>] the parsed format fields
188
191
  #
189
- # @return [Hash<Symbol, String>] author attributes for StashInfo.new
192
+ # @return [Git::AuthorInfo] the stash author; its `date` is a `Time`
190
193
  #
191
- def author_attrs(parts)
192
- {
193
- author_name: parts[Fields::AUTHOR_NAME], author_email: parts[Fields::AUTHOR_EMAIL],
194
- author_date: parts[Fields::AUTHOR_DATE]
195
- }
194
+ def author_info(parts)
195
+ build_author_info(parts[Fields::AUTHOR_NAME], parts[Fields::AUTHOR_EMAIL], parts[Fields::AUTHOR_DATE])
196
196
  end
197
197
 
198
- # Build committer-related StashInfo attributes from parsed fields
198
+ # Build the committer identity from the parsed fields
199
199
  #
200
200
  # @param parts [Array<String>] the parsed format fields
201
201
  #
202
- # @return [Hash<Symbol, String>] committer attributes for StashInfo.new
202
+ # @return [Git::AuthorInfo] the stash committer; its `date` is a `Time`
203
203
  #
204
- def committer_attrs(parts)
205
- {
206
- committer_name: parts[Fields::COMMITTER_NAME], committer_email: parts[Fields::COMMITTER_EMAIL],
207
- committer_date: parts[Fields::COMMITTER_DATE]
208
- }
204
+ def committer_info(parts)
205
+ build_author_info(
206
+ parts[Fields::COMMITTER_NAME], parts[Fields::COMMITTER_EMAIL], parts[Fields::COMMITTER_DATE]
207
+ )
208
+ end
209
+
210
+ # Build a Git::AuthorInfo from identity fields
211
+ #
212
+ # The date is parsed with `Time.iso8601`, so the UTC offset git emits for
213
+ # `%aI` and `%cI` is preserved in the resulting `Time`.
214
+ #
215
+ # @param name [String] the `%an` or `%cn` field
216
+ #
217
+ # @param email [String] the `%ae` or `%ce` field
218
+ #
219
+ # @param date [String] the `%aI` or `%cI` field in ISO 8601 format
220
+ #
221
+ # @return [Git::AuthorInfo] the identity with `date` as a `Time`
222
+ #
223
+ # @raise [Git::UnexpectedResultError] if the date is not a valid ISO 8601 date
224
+ #
225
+ def build_author_info(name, email, date)
226
+ Git::AuthorInfo.new(name: name, email: email, date: parse_date(date))
227
+ end
228
+
229
+ # Parse a `%aI` or `%cI` field into a Time
230
+ #
231
+ # @param date [String] the date field in ISO 8601 format
232
+ #
233
+ # @return [Time] the parsed time, preserving the UTC offset
234
+ #
235
+ # @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
236
+ #
237
+ def parse_date(date)
238
+ Time.iso8601(date)
239
+ rescue ArgumentError => e
240
+ raise Git::UnexpectedResultError,
241
+ "Unexpected date #{date.inspect} in output from `git stash list`: #{e.message}"
209
242
  end
210
243
 
211
244
  # Extract the stash index from a reflog selector
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
5
+ require 'git/author_info'
3
6
  require 'git/tag_info'
4
7
  require 'git/tag_delete_result'
5
8
  require 'git/tag_delete_failure'
@@ -114,7 +117,7 @@ module Git
114
117
  # where <FS> is the unit separator character ("\x1f").
115
118
  #
116
119
  # For lightweight tags, Git emits empty strings for the tagger fields and message;
117
- # these are converted to nil by {#parse_optional_field} and {#parse_message}.
120
+ # these are converted to nil by {#parse_tagger} and {#parse_message}.
118
121
  #
119
122
  # @param record [String] a single tag record from git tag --format output
120
123
  #
@@ -183,19 +186,62 @@ module Git
183
186
  def build_tag_info_object(parts, oid, target_oid)
184
187
  Git::TagInfo.new(
185
188
  name: parts[0], oid: oid, target_oid: target_oid, objecttype: parts[3],
186
- tagger_name: parse_optional_field(parts[4]), tagger_email: parse_optional_field(parts[5]),
187
- tagger_date: parse_optional_field(parts[6]), message: parse_message(parts[3], parts[7])
189
+ tagger: parse_tagger(parts[4], parts[5], parts[6]), message: parse_message(parts[3], parts[7])
188
190
  )
189
191
  end
190
192
 
191
- # Parse an optional field, returning nil if empty
193
+ # Build the tagger identity from the tagger name, email, and date fields
194
+ #
195
+ # Git emits empty strings for all three fields when there is no tag object
196
+ # (lightweight tags) or the tag object has no tagger header, in which case
197
+ # the tagger is nil. Otherwise the angle brackets git wraps around
198
+ # `%(taggeremail)` are stripped and the strict ISO 8601
199
+ # `%(taggerdate:iso8601-strict)` value is parsed into a `Time` that
200
+ # preserves the UTC offset. A partially populated identity (for example an
201
+ # empty name with an email and date) is kept as emitted rather than dropped,
202
+ # and an empty date becomes `nil`.
203
+ #
204
+ # @example An annotated tag's tagger
205
+ # parse_tagger('John Doe', '<john@example.com>', '2024-01-15T10:30:00-08:00')
206
+ # #=> #<data Git::AuthorInfo name="John Doe", email="john@example.com", ...>
207
+ #
208
+ # @example A lightweight tag has no tagger
209
+ # parse_tagger('', '', '') #=> nil
210
+ #
211
+ # @param name [String] the `%(taggername)` field
212
+ #
213
+ # @param email [String] the `%(taggeremail)` field, including angle brackets
214
+ #
215
+ # @param date [String] the `%(taggerdate:iso8601-strict)` field
216
+ #
217
+ # @return [Git::AuthorInfo, nil] the tagger, or nil when all three fields are empty
218
+ #
219
+ # @raise [Git::UnexpectedResultError] if a non-empty date is not a valid ISO 8601
220
+ # date
221
+ #
222
+ def parse_tagger(name, email, date)
223
+ return nil if [name, email, date].all?(&:empty?)
224
+
225
+ Git::AuthorInfo.new(
226
+ name: name,
227
+ email: email.delete_prefix('<').delete_suffix('>'),
228
+ date: date.empty? ? nil : parse_date(date)
229
+ )
230
+ end
231
+
232
+ # Parse a `%(taggerdate:iso8601-strict)` field into a Time
233
+ #
234
+ # @param date [String] the date field in strict ISO 8601 format
192
235
  #
193
- # @param value [String] the field value
236
+ # @return [Time] the parsed time, preserving the UTC offset
194
237
  #
195
- # @return [String, nil] the value or nil if empty
238
+ # @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
196
239
  #
197
- def parse_optional_field(value)
198
- value.empty? ? nil : value
240
+ def parse_date(date)
241
+ Time.iso8601(date)
242
+ rescue ArgumentError => e
243
+ raise Git::UnexpectedResultError,
244
+ "Unexpected tagger date #{date.inspect} in output from `git tag --list`: #{e.message}"
199
245
  end
200
246
 
201
247
  # Parse message field, returning nil for lightweight tags or empty messages
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