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
data/lib/git/object.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'git/
|
|
3
|
+
require 'git/author_info'
|
|
4
4
|
require 'git/diff'
|
|
5
5
|
require 'git/errors'
|
|
6
6
|
require 'git/log'
|
|
@@ -438,7 +438,10 @@ module Git
|
|
|
438
438
|
@parents
|
|
439
439
|
end
|
|
440
440
|
|
|
441
|
-
#
|
|
441
|
+
# Returns the commit author identity
|
|
442
|
+
#
|
|
443
|
+
# @return [Git::AuthorInfo] the author name, email, and author date
|
|
444
|
+
#
|
|
442
445
|
def author
|
|
443
446
|
check_commit
|
|
444
447
|
@author
|
|
@@ -452,7 +455,10 @@ module Git
|
|
|
452
455
|
author.date
|
|
453
456
|
end
|
|
454
457
|
|
|
455
|
-
#
|
|
458
|
+
# Returns the commit committer identity
|
|
459
|
+
#
|
|
460
|
+
# @return [Git::AuthorInfo] the committer name, email, and commit date
|
|
461
|
+
#
|
|
456
462
|
def committer
|
|
457
463
|
check_commit
|
|
458
464
|
@committer
|
|
@@ -499,8 +505,8 @@ module Git
|
|
|
499
505
|
#
|
|
500
506
|
def from_data(data)
|
|
501
507
|
@sha ||= data['sha']
|
|
502
|
-
@committer = Git::
|
|
503
|
-
@author = Git::
|
|
508
|
+
@committer = Git::AuthorInfo.parse(data['committer'])
|
|
509
|
+
@author = Git::AuthorInfo.parse(data['author'])
|
|
504
510
|
@tree = Git::Object::Tree.new(@base, data['tree'])
|
|
505
511
|
@parents = data['parent'].map { |sha| Git::Object::Commit.new(@base, sha) }
|
|
506
512
|
@message = data['message'].chomp
|
|
@@ -532,6 +538,18 @@ module Git
|
|
|
532
538
|
# Annotated tags contain additional metadata such as the tagger's name, email, and
|
|
533
539
|
# the date when the tag was created, along with a message.
|
|
534
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
|
+
#
|
|
535
553
|
class Tag < AbstractObject
|
|
536
554
|
# @return [String] the tag name
|
|
537
555
|
#
|
|
@@ -545,6 +563,13 @@ module Git
|
|
|
545
563
|
#
|
|
546
564
|
# @overload initialize(base, sha, name)
|
|
547
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
|
+
#
|
|
548
573
|
# @param base [Git::Repository] the git repository
|
|
549
574
|
#
|
|
550
575
|
# @param sha [String] the SHA of the tag object
|
|
@@ -552,12 +577,11 @@ module Git
|
|
|
552
577
|
# @param name [String] the name of the tag
|
|
553
578
|
#
|
|
554
579
|
def initialize(base, sha, name = nil)
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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)
|
|
561
585
|
super(base, sha)
|
|
562
586
|
|
|
563
587
|
@name = name
|
|
@@ -593,7 +617,7 @@ module Git
|
|
|
593
617
|
|
|
594
618
|
# Returns the tagger identity
|
|
595
619
|
#
|
|
596
|
-
# @return [Git::
|
|
620
|
+
# @return [Git::AuthorInfo, nil] the tagger for an annotated tag, or `nil`
|
|
597
621
|
# for a lightweight tag
|
|
598
622
|
#
|
|
599
623
|
def tagger
|
|
@@ -603,6 +627,31 @@ module Git
|
|
|
603
627
|
|
|
604
628
|
private
|
|
605
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
|
+
|
|
606
655
|
# Loads annotated tag data when available
|
|
607
656
|
#
|
|
608
657
|
# @return [void]
|
|
@@ -613,7 +662,7 @@ module Git
|
|
|
613
662
|
if annotated?
|
|
614
663
|
tdata = object_repository.cat_file_tag(@name)
|
|
615
664
|
@message = tdata['message'].chomp
|
|
616
|
-
@tagger = Git::
|
|
665
|
+
@tagger = Git::AuthorInfo.parse(tdata['tagger'])
|
|
617
666
|
else
|
|
618
667
|
@message = @tagger = nil
|
|
619
668
|
end
|
|
@@ -657,14 +706,19 @@ module Git
|
|
|
657
706
|
#
|
|
658
707
|
# @return [Git::Object::Tag] the tag object wrapper
|
|
659
708
|
#
|
|
660
|
-
# @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`.
|
|
661
715
|
#
|
|
662
716
|
private_class_method def self.new_tag(base, objectish)
|
|
663
717
|
Git::Deprecation.warn(
|
|
664
718
|
'Git::Object.new with is_tag argument is deprecated and will be removed in v6.0.0. ' \
|
|
665
719
|
'Use Git::Object::Tag.new instead.'
|
|
666
720
|
)
|
|
667
|
-
Git::Object::Tag.new(base, objectish)
|
|
721
|
+
Git::Deprecation.silence { Git::Object::Tag.new(base, objectish) }
|
|
668
722
|
end
|
|
669
723
|
|
|
670
724
|
# Returns the repository used for object lookup
|
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
|
|
@@ -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
|
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
|