git 5.3.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 +16 -0
- data/README.md +1 -1
- data/UPGRADING.md +320 -22
- data/lib/git/branch.rb +9 -5
- data/lib/git/object.rb +56 -8
- data/lib/git/parsers/status.rb +251 -0
- data/lib/git/parsers/worktree.rb +185 -0
- data/lib/git/repository/object_operations.rb +257 -20
- 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/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 +2 -1
- 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 +4 -0
- metadata +8 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'git/errors'
|
|
4
|
+
require 'git/status_file_info'
|
|
5
|
+
|
|
6
|
+
module Git
|
|
7
|
+
module Parsers
|
|
8
|
+
# Parser for `git status --porcelain=v2 -z` output
|
|
9
|
+
#
|
|
10
|
+
# Builds one {Git::StatusFileInfo} per entry. With `-z` every entry is
|
|
11
|
+
# NUL-terminated and paths are emitted verbatim (no quoting), so a path may
|
|
12
|
+
# contain spaces but never a NUL. The original path of a rename or copy
|
|
13
|
+
# entry is the NUL-terminated token that follows the entry.
|
|
14
|
+
#
|
|
15
|
+
# Every entry type of the porcelain v2 format is handled: `1` (ordinary),
|
|
16
|
+
# `2` (rename or copy), `u` (unmerged), `?` (untracked), and `!` (ignored).
|
|
17
|
+
# `#` header lines, emitted with `--branch` or `--show-stash`, are skipped.
|
|
18
|
+
#
|
|
19
|
+
# {Git::StatusFileInfo} lives at the top-level `Git::` namespace rather than
|
|
20
|
+
# within `Git::Parsers::` because it is public API returned to callers,
|
|
21
|
+
# while this parser is infrastructure.
|
|
22
|
+
#
|
|
23
|
+
# @example Parse the output of `git status --porcelain=v2 -z`
|
|
24
|
+
# Git::Parsers::Status.parse("? new.txt\0")
|
|
25
|
+
# #=> [#<data Git::StatusFileInfo path="new.txt", index_status="?", ...>]
|
|
26
|
+
#
|
|
27
|
+
# @see https://git-scm.com/docs/git-status#_porcelain_format_version_2
|
|
28
|
+
#
|
|
29
|
+
# @api private
|
|
30
|
+
#
|
|
31
|
+
module Status
|
|
32
|
+
# Separator between entries (and between a rename entry and its original path)
|
|
33
|
+
ENTRY_SEPARATOR = "\0"
|
|
34
|
+
|
|
35
|
+
# Separator between the fields of one entry
|
|
36
|
+
FIELD_SEPARATOR = / /
|
|
37
|
+
|
|
38
|
+
# First character of an ordinary (changed, added, or deleted) entry
|
|
39
|
+
ORDINARY_ENTRY = '1'
|
|
40
|
+
|
|
41
|
+
# First character of a rename or copy entry
|
|
42
|
+
RENAMED_ENTRY = '2'
|
|
43
|
+
|
|
44
|
+
# First character of an unmerged entry
|
|
45
|
+
UNMERGED_ENTRY = 'u'
|
|
46
|
+
|
|
47
|
+
# First character of an untracked entry
|
|
48
|
+
UNTRACKED_ENTRY = '?'
|
|
49
|
+
|
|
50
|
+
# First character of an ignored entry
|
|
51
|
+
IGNORED_ENTRY = '!'
|
|
52
|
+
|
|
53
|
+
# First character of a header line
|
|
54
|
+
HEADER_LINE = '#'
|
|
55
|
+
|
|
56
|
+
# Field count of an ordinary entry: `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>`
|
|
57
|
+
ORDINARY_FIELD_COUNT = 9
|
|
58
|
+
|
|
59
|
+
# Field count of a rename or copy entry, which adds `<X><score>` before the path
|
|
60
|
+
RENAMED_FIELD_COUNT = 10
|
|
61
|
+
|
|
62
|
+
# Field count of an unmerged entry: `u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>`
|
|
63
|
+
UNMERGED_FIELD_COUNT = 11
|
|
64
|
+
|
|
65
|
+
# Field count of an untracked or ignored entry: `? <path>` or `! <path>`
|
|
66
|
+
PATH_ONLY_FIELD_COUNT = 2
|
|
67
|
+
|
|
68
|
+
# Every {Git::StatusFileInfo} member set to `nil`, for entries that lack a field
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash{Symbol => nil}]
|
|
71
|
+
#
|
|
72
|
+
EMPTY_MEMBERS = Git::StatusFileInfo.members.to_h { |member| [member, nil] }.freeze
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# Parse `git status --porcelain=v2 -z` output into StatusFileInfo objects
|
|
77
|
+
#
|
|
78
|
+
# @example Parse two entries
|
|
79
|
+
# Git::Parsers::Status.parse(
|
|
80
|
+
# "1 .M N... 100644 100644 100644 #{sha} #{sha} lib/foo.rb\0? new.txt\0"
|
|
81
|
+
# ).map(&:path) #=> ["lib/foo.rb", "new.txt"]
|
|
82
|
+
#
|
|
83
|
+
# @param stdout [String] the NUL-separated output of `git status --porcelain=v2 -z`
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Git::StatusFileInfo>] one entry per reported path, in git's order
|
|
86
|
+
#
|
|
87
|
+
# @raise [Git::UnexpectedResultError] if an entry does not match the porcelain v2 format
|
|
88
|
+
#
|
|
89
|
+
def parse(stdout)
|
|
90
|
+
tokens = stdout.split(ENTRY_SEPARATOR)
|
|
91
|
+
files = []
|
|
92
|
+
until tokens.empty?
|
|
93
|
+
entry = tokens.shift
|
|
94
|
+
files << parse_entry(entry, tokens) unless entry.start_with?(HEADER_LINE)
|
|
95
|
+
end
|
|
96
|
+
files
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Parse one entry, consuming its original path from `tokens` for renames and copies
|
|
100
|
+
#
|
|
101
|
+
# @param entry [String] the entry line without its NUL terminator
|
|
102
|
+
#
|
|
103
|
+
# @param tokens [Array<String>] the entries that follow; a rename or copy
|
|
104
|
+
# entry's original path is shifted off the front
|
|
105
|
+
#
|
|
106
|
+
# @return [Git::StatusFileInfo] the parsed entry
|
|
107
|
+
#
|
|
108
|
+
# @raise [Git::UnexpectedResultError] if the entry type is not recognized
|
|
109
|
+
#
|
|
110
|
+
def parse_entry(entry, tokens)
|
|
111
|
+
case entry[0]
|
|
112
|
+
when ORDINARY_ENTRY then parse_ordinary(entry)
|
|
113
|
+
when RENAMED_ENTRY then parse_renamed(entry, tokens.shift)
|
|
114
|
+
when UNMERGED_ENTRY then parse_unmerged(entry)
|
|
115
|
+
when UNTRACKED_ENTRY, IGNORED_ENTRY then parse_path_only(entry)
|
|
116
|
+
else raise Git::UnexpectedResultError, unexpected_entry_error(entry)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Parse an ordinary (`1`) entry
|
|
121
|
+
#
|
|
122
|
+
# @param entry [String] the entry line
|
|
123
|
+
#
|
|
124
|
+
# @return [Git::StatusFileInfo] the parsed entry
|
|
125
|
+
#
|
|
126
|
+
# @raise [Git::UnexpectedResultError] if the entry does not have nine fields
|
|
127
|
+
#
|
|
128
|
+
def parse_ordinary(entry)
|
|
129
|
+
_type, xy, submodule, mode_head, mode_index, mode_worktree, sha_head, sha_index, path =
|
|
130
|
+
split_fields(entry, ORDINARY_FIELD_COUNT)
|
|
131
|
+
build_file_info(xy, path:, submodule:, mode_head:, mode_index:, mode_worktree:, sha_head:, sha_index:)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Parse a rename or copy (`2`) entry
|
|
135
|
+
#
|
|
136
|
+
# @param entry [String] the entry line
|
|
137
|
+
#
|
|
138
|
+
# @param original_path [String, nil] the NUL-terminated token that followed
|
|
139
|
+
# the entry, or `nil` when the output ended
|
|
140
|
+
#
|
|
141
|
+
# @return [Git::StatusFileInfo] the parsed entry
|
|
142
|
+
#
|
|
143
|
+
# @raise [Git::UnexpectedResultError] if the entry does not have ten fields
|
|
144
|
+
# or the original path is missing
|
|
145
|
+
#
|
|
146
|
+
def parse_renamed(entry, original_path)
|
|
147
|
+
_type, xy, submodule, mode_head, mode_index, mode_worktree, sha_head, sha_index, score, path =
|
|
148
|
+
split_fields(entry, RENAMED_FIELD_COUNT)
|
|
149
|
+
raise Git::UnexpectedResultError, unexpected_entry_error(entry) if original_path.nil?
|
|
150
|
+
|
|
151
|
+
build_file_info(
|
|
152
|
+
xy,
|
|
153
|
+
path:, submodule:, mode_head:, mode_index:, mode_worktree:, sha_head:, sha_index:,
|
|
154
|
+
original_path:, rename_score: score[1..].to_i
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Parse an unmerged (`u`) entry
|
|
159
|
+
#
|
|
160
|
+
# @param entry [String] the entry line
|
|
161
|
+
#
|
|
162
|
+
# @return [Git::StatusFileInfo] the parsed entry with its stage data in
|
|
163
|
+
# `unmerged_stages`
|
|
164
|
+
#
|
|
165
|
+
# @raise [Git::UnexpectedResultError] if the entry does not have eleven fields
|
|
166
|
+
#
|
|
167
|
+
def parse_unmerged(entry)
|
|
168
|
+
_type, xy, submodule, mode1, mode2, mode3, mode_worktree, sha1, sha2, sha3, path =
|
|
169
|
+
split_fields(entry, UNMERGED_FIELD_COUNT)
|
|
170
|
+
stages = unmerged_stages([mode1, mode2, mode3], [sha1, sha2, sha3])
|
|
171
|
+
build_file_info(xy, path:, submodule:, mode_worktree:, unmerged_stages: stages)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Parse an untracked (`?`) or ignored (`!`) entry
|
|
175
|
+
#
|
|
176
|
+
# The entry's single status character is used for both status positions,
|
|
177
|
+
# matching the `??` and `!!` codes of the short format.
|
|
178
|
+
#
|
|
179
|
+
# @param entry [String] the entry line
|
|
180
|
+
#
|
|
181
|
+
# @return [Git::StatusFileInfo] the parsed entry with `nil` metadata
|
|
182
|
+
#
|
|
183
|
+
# @raise [Git::UnexpectedResultError] if the entry does not have two fields
|
|
184
|
+
#
|
|
185
|
+
def parse_path_only(entry)
|
|
186
|
+
type, path = split_fields(entry, PATH_ONLY_FIELD_COUNT)
|
|
187
|
+
build_file_info(type * 2, path: path)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Split an entry into exactly `count` fields, the last of which is the path
|
|
191
|
+
#
|
|
192
|
+
# The path may contain spaces, so the split is limited to `count` fields.
|
|
193
|
+
#
|
|
194
|
+
# @param entry [String] the entry line
|
|
195
|
+
#
|
|
196
|
+
# @param count [Integer] the number of fields the entry type has
|
|
197
|
+
#
|
|
198
|
+
# @return [Array<String>] the fields
|
|
199
|
+
#
|
|
200
|
+
# @raise [Git::UnexpectedResultError] if the entry has fewer fields
|
|
201
|
+
#
|
|
202
|
+
def split_fields(entry, count)
|
|
203
|
+
fields = entry.split(FIELD_SEPARATOR, count)
|
|
204
|
+
return fields if fields.length == count
|
|
205
|
+
|
|
206
|
+
raise Git::UnexpectedResultError, unexpected_entry_error(entry)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Build the frozen stage hash of an unmerged entry
|
|
210
|
+
#
|
|
211
|
+
# @param modes [Array<String>] the stage 1, 2, and 3 modes
|
|
212
|
+
#
|
|
213
|
+
# @param shas [Array<String>] the stage 1, 2, and 3 object names
|
|
214
|
+
#
|
|
215
|
+
# @return [Hash{Integer => Hash{Symbol => String}}] frozen `\\{ mode:, sha: }`
|
|
216
|
+
# hashes keyed by stage number
|
|
217
|
+
#
|
|
218
|
+
def unmerged_stages(modes, shas)
|
|
219
|
+
modes.zip(shas).each_with_index.to_h do |(mode, sha), index|
|
|
220
|
+
[index + 1, { mode: mode, sha: sha }.freeze]
|
|
221
|
+
end.freeze
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Build a {Git::StatusFileInfo}, defaulting every member not given to `nil`
|
|
225
|
+
#
|
|
226
|
+
# @param statuses [String] the two status characters, `X` then `Y`
|
|
227
|
+
#
|
|
228
|
+
# @param members [Hash{Symbol => Object}] the members that the entry provides
|
|
229
|
+
#
|
|
230
|
+
# @option members [String] :path the repository-relative path
|
|
231
|
+
#
|
|
232
|
+
# @return [Git::StatusFileInfo] the value object
|
|
233
|
+
#
|
|
234
|
+
def build_file_info(statuses, **members)
|
|
235
|
+
Git::StatusFileInfo.new(
|
|
236
|
+
**EMPTY_MEMBERS, index_status: statuses[0], worktree_status: statuses[1], **members
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Generate the error message for an entry that does not match the format
|
|
241
|
+
#
|
|
242
|
+
# @param entry [String] the offending entry line
|
|
243
|
+
#
|
|
244
|
+
# @return [String] the message
|
|
245
|
+
#
|
|
246
|
+
def unexpected_entry_error(entry)
|
|
247
|
+
"Unexpected entry in output from `git status --porcelain=v2 -z`: #{entry.inspect}"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -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
|