git 1.19.1 → 5.5.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.
Files changed (265) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +5 -1
  3. data/AI_POLICY.md +24 -0
  4. data/CHANGELOG.md +689 -0
  5. data/CODE_OF_CONDUCT.md +25 -0
  6. data/CONTRIBUTING.md +1175 -97
  7. data/GOVERNANCE.md +106 -0
  8. data/LICENSE +1 -1
  9. data/MAINTAINERS.md +17 -4
  10. data/README.md +476 -320
  11. data/UPGRADING.md +1138 -0
  12. data/git.gemspec +124 -36
  13. data/lib/git/author.rb +39 -7
  14. data/lib/git/author_info.rb +66 -0
  15. data/lib/git/branch.rb +615 -65
  16. data/lib/git/branch_delete_failure.rb +34 -0
  17. data/lib/git/branch_delete_result.rb +66 -0
  18. data/lib/git/branch_info.rb +237 -0
  19. data/lib/git/branches.rb +167 -44
  20. data/lib/git/command_line/base.rb +247 -0
  21. data/lib/git/command_line/capturing.rb +308 -0
  22. data/lib/git/command_line/result.rb +88 -0
  23. data/lib/git/command_line/streaming.rb +236 -0
  24. data/lib/git/command_line.rb +52 -0
  25. data/lib/git/commands/add.rb +139 -0
  26. data/lib/git/commands/am/abort.rb +43 -0
  27. data/lib/git/commands/am/apply.rb +263 -0
  28. data/lib/git/commands/am/continue.rb +43 -0
  29. data/lib/git/commands/am/quit.rb +43 -0
  30. data/lib/git/commands/am/retry.rb +49 -0
  31. data/lib/git/commands/am/show_current_patch.rb +64 -0
  32. data/lib/git/commands/am/skip.rb +42 -0
  33. data/lib/git/commands/am.rb +33 -0
  34. data/lib/git/commands/apply.rb +242 -0
  35. data/lib/git/commands/archive/list_formats.rb +46 -0
  36. data/lib/git/commands/archive.rb +145 -0
  37. data/lib/git/commands/arguments.rb +4521 -0
  38. data/lib/git/commands/base.rb +502 -0
  39. data/lib/git/commands/branch/copy.rb +102 -0
  40. data/lib/git/commands/branch/create.rb +177 -0
  41. data/lib/git/commands/branch/delete.rb +88 -0
  42. data/lib/git/commands/branch/list.rb +178 -0
  43. data/lib/git/commands/branch/move.rb +102 -0
  44. data/lib/git/commands/branch/set_upstream.rb +86 -0
  45. data/lib/git/commands/branch/show_current.rb +49 -0
  46. data/lib/git/commands/branch/unset_upstream.rb +53 -0
  47. data/lib/git/commands/branch.rb +34 -0
  48. data/lib/git/commands/cat_file/batch.rb +385 -0
  49. data/lib/git/commands/cat_file/filtered.rb +105 -0
  50. data/lib/git/commands/cat_file/raw.rb +271 -0
  51. data/lib/git/commands/cat_file.rb +49 -0
  52. data/lib/git/commands/checkout/branch.rb +153 -0
  53. data/lib/git/commands/checkout/files.rb +116 -0
  54. data/lib/git/commands/checkout.rb +38 -0
  55. data/lib/git/commands/checkout_index.rb +106 -0
  56. data/lib/git/commands/clean.rb +102 -0
  57. data/lib/git/commands/clone.rb +241 -0
  58. data/lib/git/commands/commit.rb +273 -0
  59. data/lib/git/commands/commit_tree.rb +101 -0
  60. data/lib/git/commands/config_option_syntax/add.rb +86 -0
  61. data/lib/git/commands/config_option_syntax/get.rb +121 -0
  62. data/lib/git/commands/config_option_syntax/get_all.rb +118 -0
  63. data/lib/git/commands/config_option_syntax/get_color.rb +95 -0
  64. data/lib/git/commands/config_option_syntax/get_color_bool.rb +96 -0
  65. data/lib/git/commands/config_option_syntax/get_regexp.rb +119 -0
  66. data/lib/git/commands/config_option_syntax/get_urlmatch.rb +111 -0
  67. data/lib/git/commands/config_option_syntax/list.rb +111 -0
  68. data/lib/git/commands/config_option_syntax/remove_section.rb +79 -0
  69. data/lib/git/commands/config_option_syntax/rename_section.rb +83 -0
  70. data/lib/git/commands/config_option_syntax/replace_all.rb +109 -0
  71. data/lib/git/commands/config_option_syntax/set.rb +119 -0
  72. data/lib/git/commands/config_option_syntax/unset.rb +92 -0
  73. data/lib/git/commands/config_option_syntax/unset_all.rb +94 -0
  74. data/lib/git/commands/config_option_syntax.rb +56 -0
  75. data/lib/git/commands/describe.rb +156 -0
  76. data/lib/git/commands/diff.rb +657 -0
  77. data/lib/git/commands/diff_files.rb +519 -0
  78. data/lib/git/commands/diff_index.rb +499 -0
  79. data/lib/git/commands/fetch.rb +354 -0
  80. data/lib/git/commands/fsck.rb +138 -0
  81. data/lib/git/commands/gc.rb +134 -0
  82. data/lib/git/commands/grep.rb +339 -0
  83. data/lib/git/commands/init.rb +101 -0
  84. data/lib/git/commands/log.rb +634 -0
  85. data/lib/git/commands/ls_files.rb +195 -0
  86. data/lib/git/commands/ls_remote.rb +161 -0
  87. data/lib/git/commands/ls_tree.rb +135 -0
  88. data/lib/git/commands/maintenance/register.rb +77 -0
  89. data/lib/git/commands/maintenance/run.rb +109 -0
  90. data/lib/git/commands/maintenance/start.rb +71 -0
  91. data/lib/git/commands/maintenance/stop.rb +60 -0
  92. data/lib/git/commands/maintenance/unregister.rb +84 -0
  93. data/lib/git/commands/maintenance.rb +31 -0
  94. data/lib/git/commands/merge/abort.rb +44 -0
  95. data/lib/git/commands/merge/continue.rb +44 -0
  96. data/lib/git/commands/merge/quit.rb +46 -0
  97. data/lib/git/commands/merge/start.rb +250 -0
  98. data/lib/git/commands/merge.rb +28 -0
  99. data/lib/git/commands/merge_base.rb +91 -0
  100. data/lib/git/commands/mv.rb +82 -0
  101. data/lib/git/commands/name_rev.rb +119 -0
  102. data/lib/git/commands/pull.rb +382 -0
  103. data/lib/git/commands/push.rb +251 -0
  104. data/lib/git/commands/read_tree.rb +154 -0
  105. data/lib/git/commands/remote/add.rb +96 -0
  106. data/lib/git/commands/remote/get_url.rb +68 -0
  107. data/lib/git/commands/remote/list.rb +56 -0
  108. data/lib/git/commands/remote/prune.rb +63 -0
  109. data/lib/git/commands/remote/remove.rb +52 -0
  110. data/lib/git/commands/remote/rename.rb +76 -0
  111. data/lib/git/commands/remote/set_branches.rb +70 -0
  112. data/lib/git/commands/remote/set_head.rb +89 -0
  113. data/lib/git/commands/remote/set_url.rb +78 -0
  114. data/lib/git/commands/remote/set_url_add.rb +70 -0
  115. data/lib/git/commands/remote/set_url_delete.rb +71 -0
  116. data/lib/git/commands/remote/show.rb +77 -0
  117. data/lib/git/commands/remote/update.rb +79 -0
  118. data/lib/git/commands/remote.rb +42 -0
  119. data/lib/git/commands/repack.rb +281 -0
  120. data/lib/git/commands/reset.rb +154 -0
  121. data/lib/git/commands/rev_parse.rb +304 -0
  122. data/lib/git/commands/revert/abort.rb +45 -0
  123. data/lib/git/commands/revert/continue.rb +62 -0
  124. data/lib/git/commands/revert/quit.rb +47 -0
  125. data/lib/git/commands/revert/skip.rb +44 -0
  126. data/lib/git/commands/revert/start.rb +158 -0
  127. data/lib/git/commands/revert.rb +29 -0
  128. data/lib/git/commands/rm.rb +113 -0
  129. data/lib/git/commands/show.rb +632 -0
  130. data/lib/git/commands/show_ref/exclude_existing.rb +119 -0
  131. data/lib/git/commands/show_ref/exists.rb +80 -0
  132. data/lib/git/commands/show_ref/list.rb +149 -0
  133. data/lib/git/commands/show_ref/verify.rb +122 -0
  134. data/lib/git/commands/show_ref.rb +42 -0
  135. data/lib/git/commands/stash/apply.rb +81 -0
  136. data/lib/git/commands/stash/branch.rb +67 -0
  137. data/lib/git/commands/stash/clear.rb +43 -0
  138. data/lib/git/commands/stash/create.rb +60 -0
  139. data/lib/git/commands/stash/drop.rb +73 -0
  140. data/lib/git/commands/stash/list.rb +43 -0
  141. data/lib/git/commands/stash/pop.rb +87 -0
  142. data/lib/git/commands/stash/push.rb +112 -0
  143. data/lib/git/commands/stash/show.rb +158 -0
  144. data/lib/git/commands/stash/store.rb +72 -0
  145. data/lib/git/commands/stash.rb +38 -0
  146. data/lib/git/commands/status.rb +174 -0
  147. data/lib/git/commands/symbolic_ref/delete.rb +72 -0
  148. data/lib/git/commands/symbolic_ref/read.rb +99 -0
  149. data/lib/git/commands/symbolic_ref/update.rb +79 -0
  150. data/lib/git/commands/symbolic_ref.rb +38 -0
  151. data/lib/git/commands/tag/create.rb +142 -0
  152. data/lib/git/commands/tag/delete.rb +57 -0
  153. data/lib/git/commands/tag/list.rb +146 -0
  154. data/lib/git/commands/tag/verify.rb +71 -0
  155. data/lib/git/commands/tag.rb +26 -0
  156. data/lib/git/commands/update_ref/batch.rb +145 -0
  157. data/lib/git/commands/update_ref/delete.rb +90 -0
  158. data/lib/git/commands/update_ref/update.rb +103 -0
  159. data/lib/git/commands/update_ref.rb +42 -0
  160. data/lib/git/commands/version.rb +60 -0
  161. data/lib/git/commands/worktree/add.rb +139 -0
  162. data/lib/git/commands/worktree/list.rb +64 -0
  163. data/lib/git/commands/worktree/lock.rb +58 -0
  164. data/lib/git/commands/worktree/management_base.rb +51 -0
  165. data/lib/git/commands/worktree/move.rb +66 -0
  166. data/lib/git/commands/worktree/prune.rb +67 -0
  167. data/lib/git/commands/worktree/remove.rb +63 -0
  168. data/lib/git/commands/worktree/repair.rb +76 -0
  169. data/lib/git/commands/worktree/unlock.rb +47 -0
  170. data/lib/git/commands/worktree.rb +43 -0
  171. data/lib/git/commands/write_tree.rb +68 -0
  172. data/lib/git/commands.rb +88 -0
  173. data/lib/git/config.rb +72 -5
  174. data/lib/git/config_entry_info.rb +106 -0
  175. data/lib/git/configuring.rb +795 -0
  176. data/lib/git/detached_head_info.rb +57 -0
  177. data/lib/git/diff.rb +437 -86
  178. data/lib/git/diff_file_numstat_info.rb +31 -0
  179. data/lib/git/diff_file_patch_info.rb +136 -0
  180. data/lib/git/diff_file_raw_info.rb +129 -0
  181. data/lib/git/diff_info.rb +162 -0
  182. data/lib/git/diff_path_status.rb +107 -0
  183. data/lib/git/diff_result.rb +34 -0
  184. data/lib/git/diff_stats.rb +111 -0
  185. data/lib/git/dirstat_info.rb +102 -0
  186. data/lib/git/encoding_utils.rb +32 -1
  187. data/lib/git/errors.rb +285 -0
  188. data/lib/git/escaped_path.rb +57 -5
  189. data/lib/git/execution_context/global.rb +31 -0
  190. data/lib/git/execution_context/repository.rb +151 -0
  191. data/lib/git/execution_context.rb +559 -0
  192. data/lib/git/factories.rb +813 -0
  193. data/lib/git/file_ref.rb +77 -0
  194. data/lib/git/fsck_object.rb +56 -0
  195. data/lib/git/fsck_result.rb +132 -0
  196. data/lib/git/log.rb +306 -90
  197. data/lib/git/object.rb +563 -141
  198. data/lib/git/parsers/branch.rb +240 -0
  199. data/lib/git/parsers/cat_file.rb +111 -0
  200. data/lib/git/parsers/config_entry.rb +110 -0
  201. data/lib/git/parsers/diff.rb +792 -0
  202. data/lib/git/parsers/fsck.rb +144 -0
  203. data/lib/git/parsers/grep.rb +42 -0
  204. data/lib/git/parsers/ls_remote.rb +79 -0
  205. data/lib/git/parsers/ls_tree.rb +58 -0
  206. data/lib/git/parsers/remote.rb +162 -0
  207. data/lib/git/parsers/stash.rb +292 -0
  208. data/lib/git/parsers/status.rb +251 -0
  209. data/lib/git/parsers/tag.rb +341 -0
  210. data/lib/git/parsers/worktree.rb +185 -0
  211. data/lib/git/path_resolver.rb +206 -0
  212. data/lib/git/remote.rb +165 -12
  213. data/lib/git/remote_info.rb +203 -0
  214. data/lib/git/repository/branching.rb +964 -0
  215. data/lib/git/repository/committing.rb +246 -0
  216. data/lib/git/repository/context_helpers.rb +293 -0
  217. data/lib/git/repository/diffing.rb +785 -0
  218. data/lib/git/repository/inspecting.rb +252 -0
  219. data/lib/git/repository/logging.rb +410 -0
  220. data/lib/git/repository/maintenance.rb +65 -0
  221. data/lib/git/repository/merging.rb +451 -0
  222. data/lib/git/repository/object_operations.rb +1551 -0
  223. data/lib/git/repository/remote_operations.rb +984 -0
  224. data/lib/git/repository/shared_private.rb +120 -0
  225. data/lib/git/repository/staging.rb +587 -0
  226. data/lib/git/repository/stashing.rb +623 -0
  227. data/lib/git/repository/status_operations.rb +249 -0
  228. data/lib/git/repository/worktree_operations.rb +339 -0
  229. data/lib/git/repository.rb +484 -2
  230. data/lib/git/stash.rb +109 -12
  231. data/lib/git/stash_info.rb +102 -0
  232. data/lib/git/stashes.rb +169 -26
  233. data/lib/git/status.rb +308 -122
  234. data/lib/git/status_file_info.rb +258 -0
  235. data/lib/git/status_info.rb +189 -0
  236. data/lib/git/tag_delete_failure.rb +34 -0
  237. data/lib/git/tag_delete_result.rb +66 -0
  238. data/lib/git/tag_info.rb +99 -0
  239. data/lib/git/url.rb +15 -8
  240. data/lib/git/version.rb +113 -2
  241. data/lib/git/version_constraint.rb +85 -0
  242. data/lib/git/worktree.rb +150 -8
  243. data/lib/git/worktree_info.rb +128 -0
  244. data/lib/git/worktrees.rb +118 -13
  245. data/lib/git.rb +632 -234
  246. metadata +369 -54
  247. data/.github/stale.yml +0 -25
  248. data/.github/workflows/continuous_integration.yml +0 -49
  249. data/.gitignore +0 -10
  250. data/Dockerfile.changelog-rs +0 -12
  251. data/Gemfile +0 -5
  252. data/ISSUE_TEMPLATE.md +0 -15
  253. data/PULL_REQUEST_TEMPLATE.md +0 -9
  254. data/RELEASING.md +0 -70
  255. data/Rakefile +0 -60
  256. data/lib/git/base/factory.rb +0 -99
  257. data/lib/git/base.rb +0 -711
  258. data/lib/git/command_line_result.rb +0 -86
  259. data/lib/git/failed_error.rb +0 -53
  260. data/lib/git/git_execute_error.rb +0 -7
  261. data/lib/git/index.rb +0 -5
  262. data/lib/git/lib.rb +0 -1328
  263. data/lib/git/path.rb +0 -31
  264. data/lib/git/signaled_error.rb +0 -50
  265. data/lib/git/working_directory.rb +0 -4
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ require 'git/author_info'
6
+ require 'git/tag_info'
7
+ require 'git/tag_delete_result'
8
+ require 'git/tag_delete_failure'
9
+
10
+ module Git
11
+ module Parsers
12
+ # Parser for git tag command output
13
+ #
14
+ # Handles parsing of `git tag --list` and `git tag --delete` output
15
+ # into structured data objects.
16
+ #
17
+ # @note Known limitation: If a tag message contains the field delimiter
18
+ # character (\x1f, ASCII unit separator), it will be preserved correctly
19
+ # since the message is the last field. However, messages are rarely crafted
20
+ # with non-printable control characters.
21
+ #
22
+ # ## Design Note: Namespace Organization
23
+ #
24
+ # This parser creates and returns {Git::TagInfo} and {Git::TagDeleteResult}
25
+ # objects, which live at the top-level `Git::` namespace rather than within
26
+ # `Git::Parsers::`. This is intentional:
27
+ #
28
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
29
+ # interact with them directly
30
+ # - **Info/Result classes are public API** - returned by commands and used
31
+ # throughout the codebase
32
+ # - **Info classes are domain entities** - represent core git concepts
33
+ # (tags as data)
34
+ # - **Result classes are operation outcomes** - represent command results,
35
+ # not parsing details
36
+ #
37
+ # Keeping Info/Result classes at `Git::` improves discoverability and correctly
38
+ # reflects their role as public types rather than parser internals.
39
+ #
40
+ # @api private
41
+ #
42
+ module Tag
43
+ # Delimiter for separating fields in git tag --format output
44
+ # Field separator used in custom format output
45
+ # Using the ASCII unit separator (US, 0x1F / "\x1f"), a non-printable character,
46
+ # minimizes the chance of collisions with tag names or messages and remains
47
+ # safe to pass through Process.spawn and shell argument boundaries.
48
+ FIELD_DELIMITER = "\x1f"
49
+
50
+ # Delimiter for separating records (tags) in output
51
+ # Using the ASCII record separator (RS, 0x1E / "\x1e") to delimit complete tag records.
52
+ # This allows multi-line messages (which contain newlines) to be parsed correctly
53
+ # since we split by record separator first, then by field delimiter.
54
+ RECORD_DELIMITER = "\x1e"
55
+
56
+ # Number of fields expected in the parsed output
57
+ FIELD_COUNT = 8
58
+
59
+ # Format string for git tag --format
60
+ #
61
+ # Fields:
62
+ # - %(refname:short) - tag name
63
+ # - %(objectname) - SHA of the tag object (for annotated) or commit (for lightweight)
64
+ # - %(*objectname) - Dereferenced SHA (commit ID for annotated tags, empty for lightweight)
65
+ # - %(objecttype) - 'tag' for annotated tags, target object type (commit/tree/blob/etc.) for lightweight tags
66
+ # - %(taggername) - tagger name (empty for lightweight tags)
67
+ # - %(taggeremail) - tagger email (empty for lightweight tags)
68
+ # - %(taggerdate:iso8601-strict) - tagger date in strict ISO 8601 format
69
+ # - %(contents) - full tag message (can be multi-line)
70
+ #
71
+ # Each tag record is terminated by the RECORD_DELIMITER to allow multi-line messages.
72
+ FORMAT_STRING = [
73
+ '%(refname:short)',
74
+ '%(objectname)',
75
+ '%(*objectname)',
76
+ '%(objecttype)',
77
+ '%(taggername)',
78
+ '%(taggeremail)',
79
+ '%(taggerdate:iso8601-strict)',
80
+ '%(contents)'
81
+ ].join(FIELD_DELIMITER) + RECORD_DELIMITER
82
+
83
+ # Regex to parse successful deletion lines from stdout
84
+ # Matches: Deleted tag 'tagname' (was abc123)
85
+ DELETED_TAG_REGEX = /^Deleted tag '([^']+)'/
86
+
87
+ # Regex to parse error messages from stderr
88
+ # Matches: error: tag 'tagname' not found.
89
+ ERROR_TAG_REGEX = /^error: tag '([^']+)'(.*)$/
90
+
91
+ module_function
92
+
93
+ # Parse git tag --list output into TagInfo objects
94
+ #
95
+ # @example
96
+ # TagParser.parse_list("v1.0.0\x1f...\x1e\n")
97
+ # # => [#<Git::TagInfo name: "v1.0.0", ...>]
98
+ #
99
+ # @param stdout [String] output from git tag --list --format=...
100
+ #
101
+ # @return [Array<Git::TagInfo>] parsed tag information
102
+ #
103
+ # @raise [Git::UnexpectedResultError] if any record has unexpected format
104
+ #
105
+ def parse_list(stdout)
106
+ # Split by record separator
107
+ # Each record may have a leading newline from the previous record's %(contents) output
108
+ # Use lstrip to remove leading whitespace (which includes the newline) from each record
109
+ records = stdout.split(RECORD_DELIMITER).map(&:lstrip).reject(&:empty?)
110
+ records.map.with_index { |record, index| parse_tag_record(record, index, records) }
111
+ end
112
+
113
+ # Parse a single formatted tag record
114
+ #
115
+ # The record format is:
116
+ # name<FS>sha<FS>deref<FS>objecttype<FS>tagger_name<FS>tagger_email<FS>tagger_date<FS>message
117
+ # where <FS> is the unit separator character ("\x1f").
118
+ #
119
+ # For lightweight tags, Git emits empty strings for the tagger fields and message;
120
+ # these are converted to nil by {#parse_tagger} and {#parse_message}.
121
+ #
122
+ # @param record [String] a single tag record from git tag --format output
123
+ #
124
+ # @param index [Integer] record index for error reporting
125
+ #
126
+ # @param all_records [Array<String>] all output records for error messages
127
+ #
128
+ # @return [Git::TagInfo] tag info with all fields populated
129
+ #
130
+ # @raise [Git::UnexpectedResultError] if record format is unexpected
131
+ #
132
+ def parse_tag_record(record, index, all_records)
133
+ parts = record.split(FIELD_DELIMITER, FIELD_COUNT)
134
+
135
+ unless parts.length == FIELD_COUNT
136
+ raise Git::UnexpectedResultError, unexpected_tag_record_error(all_records, record, index)
137
+ end
138
+
139
+ build_tag_info(parts)
140
+ end
141
+
142
+ # Build a TagInfo object from parsed parts
143
+ #
144
+ # @param parts [Array<String>] the parsed format fields
145
+ #
146
+ # @return [Git::TagInfo]
147
+ #
148
+ # @note For annotated tags:
149
+ # - oid = %(objectname) (the tag object's ID)
150
+ # - target_oid = %(*objectname) (the dereferenced commit ID)
151
+ #
152
+ # @note For lightweight tags:
153
+ # - oid = nil (lightweight tags are not objects)
154
+ # - target_oid = %(objectname) (the commit ID)
155
+ #
156
+ def build_tag_info(parts)
157
+ oid, target_oid = resolve_oids(parts[3], parts[1], parts[2])
158
+ build_tag_info_object(parts, oid, target_oid)
159
+ end
160
+
161
+ # Resolves canonical and target object OIDs from git tag format fields
162
+ #
163
+ # @param objecttype [String] the object type from git output
164
+ #
165
+ # @param objectname [String] the object OID from %(objectname)
166
+ #
167
+ # @param dereferenced [String] the object OID from %(*objectname)
168
+ #
169
+ # @return [Array((String, nil), String)] the two-element tuple
170
+ # `[oid, target_oid]`
171
+ #
172
+ def resolve_oids(objecttype, objectname, dereferenced)
173
+ objecttype == 'tag' ? [objectname, dereferenced] : [nil, objectname]
174
+ end
175
+
176
+ # Builds a TagInfo object from normalized parser values
177
+ #
178
+ # @param parts [Array<String>] the parsed format fields
179
+ #
180
+ # @param oid [String, nil] the tag object's OID or nil for lightweight tags
181
+ #
182
+ # @param target_oid [String] the target object OID
183
+ #
184
+ # @return [Git::TagInfo] the tag info with all fields populated
185
+ #
186
+ def build_tag_info_object(parts, oid, target_oid)
187
+ Git::TagInfo.new(
188
+ name: parts[0], oid: oid, target_oid: target_oid, objecttype: parts[3],
189
+ tagger: parse_tagger(parts[4], parts[5], parts[6]), message: parse_message(parts[3], parts[7])
190
+ )
191
+ end
192
+
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
235
+ #
236
+ # @return [Time] the parsed time, preserving the UTC offset
237
+ #
238
+ # @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
239
+ #
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}"
245
+ end
246
+
247
+ # Parse message field, returning nil for lightweight tags or empty messages
248
+ # Strips trailing newlines that git adds to %(contents) output
249
+ #
250
+ # @param objecttype [String] the object type ('tag' or 'commit')
251
+ #
252
+ # @param message [String] the raw message field
253
+ #
254
+ # @return [String, nil] the message or nil
255
+ #
256
+ def parse_message(objecttype, message)
257
+ stripped = message.chomp
258
+ objecttype == 'tag' && !stripped.empty? ? stripped : nil
259
+ end
260
+
261
+ # Parse deleted tag names from stdout
262
+ #
263
+ # @example
264
+ # TagParser.parse_deleted_tags("Deleted tag 'v1.0.0' (was abc123)\n")
265
+ # # => ["v1.0.0"]
266
+ #
267
+ # @param stdout [String] command stdout
268
+ #
269
+ # @return [Array<String>] names of successfully deleted tags
270
+ #
271
+ def parse_deleted_tags(stdout)
272
+ stdout.scan(DELETED_TAG_REGEX).flatten
273
+ end
274
+
275
+ # Parse error messages from stderr into a map
276
+ #
277
+ # @example
278
+ # TagParser.parse_error_messages("error: tag 'missing' not found.\n")
279
+ # # => {"missing" => "error: tag 'missing' not found."}
280
+ #
281
+ # @param stderr [String] command stderr
282
+ #
283
+ # @return [Hash<String, String>] map of tag name to error message
284
+ #
285
+ def parse_error_messages(stderr)
286
+ stderr.each_line.with_object({}) do |line, hash|
287
+ match = line.match(ERROR_TAG_REGEX)
288
+ hash[match[1]] = line.strip if match
289
+ end
290
+ end
291
+
292
+ # Build the TagDeleteResult from parsed data
293
+ #
294
+ # @param requested_names [Array<String>] originally requested tag names
295
+ #
296
+ # @param existing_tags [Hash<String, Git::TagInfo>] tags that existed before delete
297
+ #
298
+ # @param deleted_names [Array<String>] names confirmed deleted in stdout
299
+ #
300
+ # @param error_map [Hash<String, String>] map of tag name to error message
301
+ #
302
+ # @return [Git::TagDeleteResult] the result object
303
+ #
304
+ def build_delete_result(requested_names, existing_tags, deleted_names, error_map)
305
+ deleted = deleted_names.filter_map { |name| existing_tags[name] }
306
+
307
+ not_deleted = (requested_names - deleted_names).map do |name|
308
+ error_message = error_map[name] || "tag '#{name}' could not be deleted"
309
+ Git::TagDeleteFailure.new(name: name, error_message: error_message)
310
+ end
311
+
312
+ Git::TagDeleteResult.new(deleted: deleted, not_deleted: not_deleted)
313
+ end
314
+
315
+ # Generate error message for unexpected tag record format
316
+ #
317
+ # @param records [Array<String>] all output records
318
+ #
319
+ # @param record [String] the problematic record
320
+ #
321
+ # @param index [Integer] the record index
322
+ #
323
+ # @return [String] formatted error message
324
+ #
325
+ def unexpected_tag_record_error(records, record, index)
326
+ format_str = FORMAT_STRING.gsub(FIELD_DELIMITER, '<FS>').gsub(RECORD_DELIMITER, '<RS>')
327
+ <<~ERROR
328
+ Unexpected record in output from `git tag --list --format=#{format_str}`, at index #{index}
329
+
330
+ Expected #{FIELD_COUNT} fields separated by '\\x1f' (unit separator), got #{record.split(FIELD_DELIMITER, -1).length}
331
+
332
+ Full output:
333
+ #{records.join("\n ")}
334
+
335
+ Record at index #{index}:
336
+ "#{record}"
337
+ ERROR
338
+ end
339
+ end
340
+ end
341
+ 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