git 5.3.0 → 5.4.1
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 +28 -0
- data/README.md +1 -1
- data/UPGRADING.md +500 -31
- 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
data/lib/git/object.rb
CHANGED
|
@@ -538,6 +538,18 @@ module Git
|
|
|
538
538
|
# Annotated tags contain additional metadata such as the tagger's name, email, and
|
|
539
539
|
# the date when the tag was created, along with a message.
|
|
540
540
|
#
|
|
541
|
+
# @deprecated Use {Git::Repository::ObjectOperations#tag_list} and
|
|
542
|
+
# {Git::TagInfo} instead
|
|
543
|
+
#
|
|
544
|
+
# {Git::TagInfo} is an immutable value object carrying the tag's `name`,
|
|
545
|
+
# `oid`, `target_oid`, `annotated?`, `message`, and `tagger`. Call the
|
|
546
|
+
# corresponding {Git::Repository} method (e.g. `archive`, `log`, `diff`,
|
|
547
|
+
# `cat_file_contents`) with `info.oid || info.target_oid` for operations
|
|
548
|
+
# on a tag; that is the object this class resolves and pins at
|
|
549
|
+
# construction, so a later move of the tag does not redirect an existing
|
|
550
|
+
# object, whereas the tag name would. Constructing a `Git::Object::Tag`
|
|
551
|
+
# emits a deprecation warning.
|
|
552
|
+
#
|
|
541
553
|
class Tag < AbstractObject
|
|
542
554
|
# @return [String] the tag name
|
|
543
555
|
#
|
|
@@ -551,6 +563,13 @@ module Git
|
|
|
551
563
|
#
|
|
552
564
|
# @overload initialize(base, sha, name)
|
|
553
565
|
#
|
|
566
|
+
# `sha` is kept as the object that the inherited operations (`size`,
|
|
567
|
+
# `contents`, `grep`, `diff`, `log`, `archive`) run against; `annotated?`,
|
|
568
|
+
# `message`, and `tagger` read the ref `name`. {Git::TagInfo} describes a
|
|
569
|
+
# ref, so there is no OID-based replacement for this form: pass `sha` to
|
|
570
|
+
# the {Git::Repository} operation directly, or read the tag object with
|
|
571
|
+
# {Git::Repository::ObjectOperations#cat_file_tag}.
|
|
572
|
+
#
|
|
554
573
|
# @param base [Git::Repository] the git repository
|
|
555
574
|
#
|
|
556
575
|
# @param sha [String] the SHA of the tag object
|
|
@@ -558,12 +577,11 @@ module Git
|
|
|
558
577
|
# @param name [String] the name of the tag
|
|
559
578
|
#
|
|
560
579
|
def initialize(base, sha, name = nil)
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
580
|
+
Git::Deprecation.warn(
|
|
581
|
+
'Git::Object::Tag is deprecated and will be removed in v6.0.0. ' \
|
|
582
|
+
'Use Git::Repository#tag_list and Git::TagInfo instead.'
|
|
583
|
+
)
|
|
584
|
+
sha, name = resolve_sha_and_name(base, sha, name)
|
|
567
585
|
super(base, sha)
|
|
568
586
|
|
|
569
587
|
@name = name
|
|
@@ -609,6 +627,31 @@ module Git
|
|
|
609
627
|
|
|
610
628
|
private
|
|
611
629
|
|
|
630
|
+
# Resolves the two-argument constructor form to a SHA and a tag name
|
|
631
|
+
#
|
|
632
|
+
# In the two-argument form `sha` carries the tag name and the SHA is
|
|
633
|
+
# looked up from the repository.
|
|
634
|
+
#
|
|
635
|
+
# @param base [Git::Repository] the git repository
|
|
636
|
+
#
|
|
637
|
+
# @param sha [String] the SHA of the tag object, or the tag name in the
|
|
638
|
+
# two-argument form
|
|
639
|
+
#
|
|
640
|
+
# @param name [String, nil] the tag name, or `nil` in the two-argument form
|
|
641
|
+
#
|
|
642
|
+
# @return [Array(String, String)] the resolved `[sha, name]` pair
|
|
643
|
+
#
|
|
644
|
+
# @raise [Git::UnexpectedResultError] if the tag does not exist
|
|
645
|
+
#
|
|
646
|
+
def resolve_sha_and_name(base, sha, name)
|
|
647
|
+
return [sha, name] unless name.nil?
|
|
648
|
+
|
|
649
|
+
resolved = base.tag_sha(sha)
|
|
650
|
+
raise Git::UnexpectedResultError, "Tag '#{sha}' does not exist." if resolved == ''
|
|
651
|
+
|
|
652
|
+
[resolved, sha]
|
|
653
|
+
end
|
|
654
|
+
|
|
612
655
|
# Loads annotated tag data when available
|
|
613
656
|
#
|
|
614
657
|
# @return [void]
|
|
@@ -663,14 +706,19 @@ module Git
|
|
|
663
706
|
#
|
|
664
707
|
# @return [Git::Object::Tag] the tag object wrapper
|
|
665
708
|
#
|
|
666
|
-
# @deprecated
|
|
709
|
+
# @deprecated Use {Git::Repository::ObjectOperations#tag_list} instead
|
|
710
|
+
#
|
|
711
|
+
# The warning names `Git::Object::Tag.new`, the replacement this path
|
|
712
|
+
# shipped with, and the {Git::Object::Tag} constructor is deprecated as
|
|
713
|
+
# well; this method silences it so one call emits one warning. Go
|
|
714
|
+
# straight to `Git::Repository#tag_list(name).first`.
|
|
667
715
|
#
|
|
668
716
|
private_class_method def self.new_tag(base, objectish)
|
|
669
717
|
Git::Deprecation.warn(
|
|
670
718
|
'Git::Object.new with is_tag argument is deprecated and will be removed in v6.0.0. ' \
|
|
671
719
|
'Use Git::Object::Tag.new instead.'
|
|
672
720
|
)
|
|
673
|
-
Git::Object::Tag.new(base, objectish)
|
|
721
|
+
Git::Deprecation.silence { Git::Object::Tag.new(base, objectish) }
|
|
674
722
|
end
|
|
675
723
|
|
|
676
724
|
# Returns the repository used for object lookup
|
|
@@ -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
|