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.
data/lib/git/object.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'git/author'
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
- # git author
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
- # git author
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::Author.new(data['committer'])
503
- @author = Git::Author.new(data['author'])
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
- if name.nil?
556
- name = sha
557
- sha = base.tag_sha(name)
558
- raise Git::UnexpectedResultError, "Tag '#{name}' does not exist." if sha == ''
559
- end
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::Author, nil] the tagger for an annotated tag, or `nil`
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::Author.new(tdata['tagger'])
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 use `Git::Object::Tag.new` instead
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
@@ -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(author_attrs(parts)).merge(committer_attrs(parts))
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-related StashInfo attributes from parsed fields
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 [Hash<Symbol, String>] author attributes for StashInfo.new
192
+ # @return [Git::AuthorInfo] the stash author; its `date` is a `Time`
190
193
  #
191
- def author_attrs(parts)
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-related StashInfo attributes from parsed fields
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 [Hash<Symbol, String>] committer attributes for StashInfo.new
202
+ # @return [Git::AuthorInfo] the stash committer; its `date` is a `Time`
203
203
  #
204
- def committer_attrs(parts)
205
- {
206
- committer_name: parts[Fields::COMMITTER_NAME], committer_email: parts[Fields::COMMITTER_EMAIL],
207
- committer_date: parts[Fields::COMMITTER_DATE]
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
@@ -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 {#parse_optional_field} and {#parse_message}.
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
- tagger_name: parse_optional_field(parts[4]), tagger_email: parse_optional_field(parts[5]),
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
- # Parse an optional field, returning nil if empty
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
- # @param value [String] the field value
236
+ # @return [Time] the parsed time, preserving the UTC offset
194
237
  #
195
- # @return [String, nil] the value or nil if empty
238
+ # @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
196
239
  #
197
- def parse_optional_field(value)
198
- value.empty? ? nil : value
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