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,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ require 'git/author_info'
6
+ require 'git/stash_info'
7
+
8
+ module Git
9
+ module Parsers
10
+ # Parser for git stash command output
11
+ #
12
+ # Handles parsing of `git stash list` output into structured data objects.
13
+ #
14
+ # @note Known limitation: If a stash message contains the field separator
15
+ # character (\x1f, ASCII unit separator), parsing will fail or produce
16
+ # incorrect results. This is extremely rare in practice since \x1f is a
17
+ # non-printable control character.
18
+ #
19
+ # ## Design Note: Namespace Organization
20
+ #
21
+ # This parser creates and returns {Git::StashInfo} objects, which live at
22
+ # the top-level `Git::` namespace rather than within `Git::Parsers::`. This
23
+ # is intentional:
24
+ #
25
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
26
+ # interact with them directly
27
+ # - **Info classes are public API** - returned by commands and used throughout
28
+ # the codebase
29
+ # - **Info classes are domain entities** - represent core git concepts
30
+ # (stashes as data)
31
+ #
32
+ # Keeping Info classes at `Git::` improves discoverability and correctly
33
+ # reflects their role as public types rather than parser internals.
34
+ #
35
+ # @api private
36
+ #
37
+ module Stash
38
+ # Field separator used in custom format output
39
+ # Using a non-printable unit separator (US, 0x1F) to avoid collisions with
40
+ # stash messages and author/committer fields, while still working with
41
+ # Process.spawn (which doesn't allow NUL bytes in arguments)
42
+ FIELD_SEPARATOR = "\x1f"
43
+
44
+ # Custom format for git stash list that extracts all available metadata
45
+ # %H = full commit SHA
46
+ # %h = abbreviated commit SHA
47
+ # %gd = reflog selector (stash@\\{n})
48
+ # %gs = reflog subject (the stash message)
49
+ # %an = author name
50
+ # %ae = author email
51
+ # %aI = author date (ISO 8601 format, parsed into a Time)
52
+ # %cn = committer name
53
+ # %ce = committer email
54
+ # %cI = committer date (ISO 8601 format, parsed into a Time)
55
+ STASH_FORMAT = [
56
+ '%H', # 0: full SHA
57
+ '%h', # 1: short SHA
58
+ '%gd', # 2: reflog selector
59
+ '%gs', # 3: reflog subject (message)
60
+ '%an', # 4: author name
61
+ '%ae', # 5: author email
62
+ '%aI', # 6: author date
63
+ '%cn', # 7: committer name
64
+ '%ce', # 8: committer email
65
+ '%cI' # 9: committer date
66
+ ].join(FIELD_SEPARATOR)
67
+
68
+ # Number of fields expected in the parsed output
69
+ FIELD_COUNT = 10
70
+
71
+ # Field indices for parsed output
72
+ module Fields
73
+ # Index of the full object ID (`%H`) field
74
+ OID = 0
75
+
76
+ # Index of the abbreviated object ID (`%h`) field
77
+ SHORT_OID = 1
78
+
79
+ # Index of the reflog selector (`%gd`) field
80
+ REFLOG = 2
81
+
82
+ # Index of the reflog subject (`%gs`) field
83
+ MESSAGE = 3
84
+
85
+ # Index of the author name (`%an`) field
86
+ AUTHOR_NAME = 4
87
+
88
+ # Index of the author email (`%ae`) field
89
+ AUTHOR_EMAIL = 5
90
+
91
+ # Index of the author date (`%aI`) field
92
+ AUTHOR_DATE = 6
93
+
94
+ # Index of the committer name (`%cn`) field
95
+ COMMITTER_NAME = 7
96
+
97
+ # Index of the committer email (`%ce`) field
98
+ COMMITTER_EMAIL = 8
99
+
100
+ # Index of the committer date (`%cI`) field
101
+ COMMITTER_DATE = 9
102
+ end
103
+
104
+ # Pattern to extract branch from standard stash messages
105
+ # Matches "WIP on <branch>:" or "On <branch>:" at the start
106
+ BRANCH_PATTERN = /^(?:WIP on|On)\s+([^:]+):/
107
+
108
+ module_function
109
+
110
+ # Parse git stash list output into StashInfo objects
111
+ #
112
+ # @example
113
+ # StashParser.parse_list("abc123\x1fabc\x1fstash@\\{0}\x1fWIP on main: msg\x1f...\n")
114
+ # # => [#<Git::StashInfo index: 0, ...>]
115
+ #
116
+ # @param stdout [String] output from git stash list --format=...
117
+ #
118
+ # @return [Array<Git::StashInfo>] parsed stash information
119
+ #
120
+ # @raise [Git::UnexpectedResultError] if stash output cannot be parsed
121
+ #
122
+ def parse_list(stdout)
123
+ lines = stdout.split("\n")
124
+ lines.each_with_index.map { |line, idx| parse_stash_line(line, idx, lines) }
125
+ end
126
+
127
+ # Parse a single stash list line into a StashInfo object
128
+ #
129
+ # @param line [String] a line from git stash list output (custom format)
130
+ #
131
+ # @param expected_index [Integer] the expected stash index for validation
132
+ #
133
+ # @param all_lines [Array<String>] all output lines (for error messages)
134
+ #
135
+ # @return [Git::StashInfo] parsed stash info
136
+ #
137
+ # @raise [Git::UnexpectedResultError] if line format is unexpected
138
+ #
139
+ def parse_stash_line(line, expected_index, all_lines)
140
+ parts = line.split(FIELD_SEPARATOR, FIELD_COUNT)
141
+ return build_stash_info(parts, expected_index) if parts.length == FIELD_COUNT
142
+
143
+ raise Git::UnexpectedResultError, unexpected_stash_line_error(all_lines, line, expected_index)
144
+ end
145
+
146
+ # Build a StashInfo from parsed format parts
147
+ #
148
+ # @param parts [Array<String>] the parsed format fields
149
+ #
150
+ # @param expected_index [Integer] fallback index if not parseable from reflog
151
+ #
152
+ # @return [Git::StashInfo]
153
+ #
154
+ def build_stash_info(parts, expected_index)
155
+ index = extract_index(parts[Fields::REFLOG]) || expected_index
156
+
157
+ Git::StashInfo.new(**stash_info_attrs(parts, index))
158
+ end
159
+
160
+ # Build StashInfo attributes hash from parsed parts
161
+ #
162
+ # @param parts [Array<String>] the parsed format fields
163
+ #
164
+ # @param index [Integer] the resolved stash index
165
+ #
166
+ # @return [Hash] attributes for StashInfo.new
167
+ #
168
+ def stash_info_attrs(parts, index)
169
+ core_attrs(parts, index).merge(author: author_info(parts), committer: committer_info(parts))
170
+ end
171
+
172
+ # Build core StashInfo attributes from parsed fields
173
+ #
174
+ # @param parts [Array<String>] the parsed format fields
175
+ #
176
+ # @param index [Integer] the resolved stash index
177
+ #
178
+ # @return [Hash<Symbol, Object>] core attributes for StashInfo.new
179
+ #
180
+ def core_attrs(parts, index)
181
+ {
182
+ index: index, name: parts[Fields::REFLOG], oid: parts[Fields::OID],
183
+ short_oid: parts[Fields::SHORT_OID], branch: extract_branch(parts[Fields::MESSAGE]),
184
+ message: parts[Fields::MESSAGE]
185
+ }
186
+ end
187
+
188
+ # Build the author identity from the parsed fields
189
+ #
190
+ # @param parts [Array<String>] the parsed format fields
191
+ #
192
+ # @return [Git::AuthorInfo] the stash author; its `date` is a `Time`
193
+ #
194
+ def author_info(parts)
195
+ build_author_info(parts[Fields::AUTHOR_NAME], parts[Fields::AUTHOR_EMAIL], parts[Fields::AUTHOR_DATE])
196
+ end
197
+
198
+ # Build the committer identity from the parsed fields
199
+ #
200
+ # @param parts [Array<String>] the parsed format fields
201
+ #
202
+ # @return [Git::AuthorInfo] the stash committer; its `date` is a `Time`
203
+ #
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}"
242
+ end
243
+
244
+ # Extract the stash index from a reflog selector
245
+ #
246
+ # @param reflog_selector [String] e.g., "stash@\\{0}"
247
+ #
248
+ # @return [Integer, nil] the index or nil if not found
249
+ #
250
+ def extract_index(reflog_selector)
251
+ match = reflog_selector&.match(/stash@\{(\d+)\}/)
252
+ match ? match[1].to_i : nil
253
+ end
254
+
255
+ # Extract the branch name from a stash message
256
+ #
257
+ # @param message [String] the stash message
258
+ #
259
+ # @return [String, nil] the branch name or nil for custom messages
260
+ #
261
+ def extract_branch(message)
262
+ match = BRANCH_PATTERN.match(message)
263
+ match ? match[1] : nil
264
+ end
265
+
266
+ # Generate error message for unexpected stash line format
267
+ #
268
+ # @param lines [Array<String>] all output lines
269
+ #
270
+ # @param line [String] the problematic line
271
+ #
272
+ # @param index [Integer] the stash index
273
+ #
274
+ # @return [String] formatted error message
275
+ #
276
+ def unexpected_stash_line_error(lines, line, index)
277
+ format_str = STASH_FORMAT.gsub(FIELD_SEPARATOR, '<FS>')
278
+ <<~ERROR
279
+ Unexpected line in output from `git stash list --format=#{format_str}`, at index #{index}
280
+
281
+ Expected #{FIELD_COUNT} fields separated by '\\x1f' (unit separator), got #{line.split(FIELD_SEPARATOR, -1).length}
282
+
283
+ Full output:
284
+ #{lines.join("\n ")}
285
+
286
+ Line at index #{index}:
287
+ "#{line}"
288
+ ERROR
289
+ end
290
+ end
291
+ end
292
+ end
@@ -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