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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/CONTRIBUTING.md +17 -4
- data/README.md +49 -8
- data/UPGRADING.md +587 -3
- data/lib/git/author.rb +11 -0
- data/lib/git/author_info.rb +66 -0
- data/lib/git/branch.rb +216 -17
- 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 +69 -15
- data/lib/git/parsers/stash.rb +50 -17
- data/lib/git/parsers/status.rb +251 -0
- data/lib/git/parsers/tag.rb +54 -8
- data/lib/git/parsers/worktree.rb +185 -0
- 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/object_operations.rb +257 -20
- data/lib/git/repository/remote_operations.rb +57 -0
- data/lib/git/repository/shared_private.rb +67 -0
- data/lib/git/repository/stashing.rb +532 -52
- data/lib/git/repository/status_operations.rb +56 -8
- data/lib/git/repository/worktree_operations.rb +198 -18
- data/lib/git/stash.rb +31 -2
- data/lib/git/stash_info.rb +32 -34
- data/lib/git/stashes.rb +47 -11
- data/lib/git/status.rb +14 -0
- data/lib/git/status_file_info.rb +258 -0
- data/lib/git/status_info.rb +189 -0
- data/lib/git/tag_info.rb +23 -30
- data/lib/git/version.rb +1 -1
- data/lib/git/worktree.rb +39 -0
- data/lib/git/worktree_info.rb +128 -0
- data/lib/git/worktrees.rb +19 -1
- data/lib/git.rb +5 -0
- metadata +9 -3
|
@@ -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.
|
|
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
|