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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/CONTRIBUTING.md +17 -4
- data/README.md +48 -7
- data/UPGRADING.md +287 -1
- data/lib/git/author.rb +11 -0
- data/lib/git/author_info.rb +66 -0
- data/lib/git/branch.rb +210 -15
- data/lib/git/branch_info.rb +1 -1
- data/lib/git/branches.rb +35 -7
- data/lib/git/commands/cat_file/raw.rb +60 -6
- data/lib/git/object.rb +13 -7
- data/lib/git/parsers/stash.rb +50 -17
- data/lib/git/parsers/tag.rb +54 -8
- data/lib/git/remote.rb +37 -7
- data/lib/git/remote_info.rb +67 -10
- data/lib/git/repository/branching.rb +111 -1
- data/lib/git/repository/merging.rb +96 -2
- data/lib/git/repository/remote_operations.rb +57 -0
- data/lib/git/repository/shared_private.rb +67 -0
- data/lib/git/stash_info.rb +32 -34
- data/lib/git/tag_info.rb +21 -29
- data/lib/git/version.rb +1 -1
- data/lib/git.rb +1 -0
- metadata +4 -3
data/lib/git/parsers/stash.rb
CHANGED
|
@@ -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(
|
|
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
|
|
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 [
|
|
192
|
+
# @return [Git::AuthorInfo] the stash author; its `date` is a `Time`
|
|
190
193
|
#
|
|
191
|
-
def
|
|
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
|
|
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 [
|
|
202
|
+
# @return [Git::AuthorInfo] the stash committer; its `date` is a `Time`
|
|
203
203
|
#
|
|
204
|
-
def
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
data/lib/git/parsers/tag.rb
CHANGED
|
@@ -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 {#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
236
|
+
# @return [Time] the parsed time, preserving the UTC offset
|
|
194
237
|
#
|
|
195
|
-
# @
|
|
238
|
+
# @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
|
|
196
239
|
#
|
|
197
|
-
def
|
|
198
|
-
|
|
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.
|
|
11
|
-
#
|
|
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
|
|
15
|
+
# @example Reading a remote and fetching from it without Git::Remote
|
|
14
16
|
# git = Git.open('.')
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
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
|
-
|
|
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}")
|
data/lib/git/remote_info.rb
CHANGED
|
@@ -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
|
-
#
|
|
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),
|
|
140
|
-
push: Array(push), mirror:,
|
|
141
|
-
|
|
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
|