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,792 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/diff_result'
4
+ require 'git/diff_file_numstat_info'
5
+ require 'git/diff_file_raw_info'
6
+ require 'git/diff_file_patch_info'
7
+ require 'git/dirstat_info'
8
+
9
+ module Git
10
+ module Parsers
11
+ # Parser for git diff output in various formats
12
+ #
13
+ # Handles parsing of --numstat, --shortstat, --dirstat, --raw, and --patch output.
14
+ # This parser is used by stash show, diff, show, and log commands.
15
+ #
16
+ # @note Combined/merge diffs (e.g., from `git diff --cc` or `git show <merge>`) are not
17
+ # supported. These have a different format with multiple +/- columns per parent.
18
+ #
19
+ # ## Design Note: Namespace Organization
20
+ #
21
+ # This parser creates and returns {Git::DiffResult} and {Git::DiffFile*Info}
22
+ # objects, which live at the top-level `Git::` namespace rather than within
23
+ # `Git::Parsers::`. This is intentional:
24
+ #
25
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
26
+ # interact with them directly
27
+ # - **Info/Result classes are public API** - returned by commands and used
28
+ # throughout the codebase
29
+ # - **Info classes are domain entities** - represent git diff data
30
+ # - **Result classes are operation outcomes** - represent command results,
31
+ # not parsing details
32
+ #
33
+ # Keeping Info/Result classes at `Git::` improves discoverability and correctly
34
+ # reflects their role as public types rather than parser internals.
35
+ #
36
+ # @api private
37
+ #
38
+ module Diff
39
+ # Status letter to symbol mapping for --raw output
40
+ STATUS_MAP = {
41
+ 'A' => :added,
42
+ 'M' => :modified,
43
+ 'D' => :deleted,
44
+ 'R' => :renamed,
45
+ 'C' => :copied,
46
+ 'T' => :type_changed
47
+ }.freeze
48
+
49
+ # Null SHA for non-existent files
50
+ NULL_SHA = '0' * 7
51
+
52
+ # Null mode for non-existent files
53
+ NULL_MODE = '000000'
54
+
55
+ # Rename format patterns from git numstat -M output:
56
+ # old_name.rb => new_name.rb
57
+ # \\{old_dir => new_dir}/file.rb
58
+ # dir/\\{old_name.rb => new_name.rb}
59
+ RENAME_PATTERN = /\A(.+) => (.+)\z/
60
+
61
+ # Brace-expanded rename format from git numstat -M output
62
+ BRACE_RENAME_PATTERN = /\A(.*)\{(.+) => (.+)\}(.*)\z/
63
+
64
+ module_function
65
+
66
+ # Build a DiffResult from parsed components
67
+ #
68
+ # @param files [Array] array of file info objects
69
+ #
70
+ # @param shortstat [Hash] parsed shortstat data
71
+ #
72
+ # @param dirstat [Git::DirstatInfo, nil] parsed dirstat data
73
+ #
74
+ # @return [Git::DiffResult]
75
+ #
76
+ def build_result(files:, shortstat:, dirstat:)
77
+ Git::DiffResult.new(
78
+ files_changed: shortstat[:files_changed],
79
+ total_insertions: shortstat[:insertions],
80
+ total_deletions: shortstat[:deletions],
81
+ files: files,
82
+ dirstat: dirstat
83
+ )
84
+ end
85
+
86
+ # Build an empty DiffResult
87
+ #
88
+ # @return [Git::DiffResult]
89
+ #
90
+ def empty_result
91
+ Git::DiffResult.new(
92
+ files_changed: 0, total_insertions: 0, total_deletions: 0,
93
+ files: [], dirstat: nil
94
+ )
95
+ end
96
+
97
+ # Parse shortstat line into components
98
+ #
99
+ # @example
100
+ # parse_shortstat(" 3 files changed, 10 insertions(+), 5 deletions(-)")
101
+ # # => { files_changed: 3, insertions: 10, deletions: 5 }
102
+ #
103
+ # @param line [String, nil] the shortstat line
104
+ #
105
+ # @return [Hash] { files_changed:, insertions:, deletions: }
106
+ #
107
+ def parse_shortstat(line)
108
+ return { files_changed: 0, insertions: 0, deletions: 0 } if line.nil?
109
+
110
+ {
111
+ files_changed: line.match(/(\d+)\s+files?\s+changed/)&.[](1).to_i,
112
+ insertions: line.match(/(\d+)\s+insertions?\(\+\)/)&.[](1).to_i,
113
+ deletions: line.match(/(\d+)\s+deletions?\(-\)/)&.[](1).to_i
114
+ }
115
+ end
116
+
117
+ # Parse dirstat lines into DirstatInfo
118
+ #
119
+ # @example
120
+ # parse_dirstat([" 50.0% lib/", " 50.0% spec/"])
121
+ # # => #<Git::DirstatInfo entries: [...]>
122
+ #
123
+ # @param lines [Array<String>] dirstat output lines
124
+ #
125
+ # @return [Git::DirstatInfo]
126
+ #
127
+ def parse_dirstat(lines)
128
+ entries = lines.filter_map do |line|
129
+ next unless (match = line.match(/^\s*([\d.]+)%\s+(.+)$/))
130
+
131
+ Git::DirstatEntry.new(percentage: match[1].to_f, directory: match[2])
132
+ end
133
+ Git::DirstatInfo.new(entries: entries)
134
+ end
135
+
136
+ # Parse a stat value (handles '-' for binary files)
137
+ #
138
+ # @param value [String] the stat value string
139
+ #
140
+ # @return [Integer] the numeric value (0 for binary files)
141
+ #
142
+ def parse_stat_value(value)
143
+ value == '-' ? 0 : value.to_i
144
+ end
145
+
146
+ # Unescape quoted path from git output
147
+ #
148
+ # @param path [String] potentially quoted path
149
+ #
150
+ # @return [String] unescaped path
151
+ #
152
+ def unescape_path(path)
153
+ return path unless path&.start_with?('"') && path.end_with?('"')
154
+
155
+ Git::EscapedPath.new(path[1..-2]).unescape
156
+ end
157
+
158
+ # Parser for --numstat output
159
+ module Numstat
160
+ module_function
161
+
162
+ # Parse numstat output into DiffResult
163
+ #
164
+ # @param output [String] raw numstat + shortstat output
165
+ #
166
+ # @param include_dirstat [Boolean] whether dirstat output is expected
167
+ #
168
+ # @return [Git::DiffResult]
169
+ #
170
+ def parse(output, include_dirstat: false)
171
+ lines = output.split("\n").reject(&:empty?)
172
+ numstat_lines, shortstat_line, dirstat_lines = split_sections(lines, include_dirstat)
173
+
174
+ Diff.build_result(
175
+ files: parse_file_stats(numstat_lines),
176
+ shortstat: Diff.parse_shortstat(shortstat_line),
177
+ dirstat: include_dirstat ? Diff.parse_dirstat(dirstat_lines) : nil
178
+ )
179
+ end
180
+
181
+ # Parse numstat lines into DiffFileNumstatInfo array
182
+ #
183
+ # @param lines [Array<String>] numstat lines
184
+ #
185
+ # @return [Array<Git::DiffFileNumstatInfo>]
186
+ #
187
+ def parse_file_stats(lines)
188
+ lines.map do |line|
189
+ insertions_s, deletions_s, filename = line.split("\t", 3)
190
+ path, src_path = parse_rename_path(filename)
191
+ Git::DiffFileNumstatInfo.new(
192
+ path: Diff.unescape_path(path),
193
+ src_path: src_path ? Diff.unescape_path(src_path) : nil,
194
+ insertions: Diff.parse_stat_value(insertions_s),
195
+ deletions: Diff.parse_stat_value(deletions_s)
196
+ )
197
+ end
198
+ end
199
+
200
+ # Parse numstat lines into a path -> stats hash (for combining with other formats)
201
+ #
202
+ # @param lines [Array<String>] numstat lines
203
+ #
204
+ # @param include_binary [Boolean] whether to include binary flag
205
+ #
206
+ # @return [Hash<String, Hash>] path to stats mapping (keyed by destination path)
207
+ #
208
+ def parse_as_map(lines, include_binary: false)
209
+ lines.to_h do |line|
210
+ insertions_s, deletions_s, filename = line.split("\t", 3)
211
+ # Normalize rename paths so the key matches the dst_path used by raw/patch parsers
212
+ dst_path, _src_path = parse_rename_path(filename)
213
+ [Diff.unescape_path(dst_path), build_stats(insertions_s, deletions_s, include_binary)]
214
+ end
215
+ end
216
+
217
+ # Builds stats data from a numstat entry
218
+ #
219
+ # @param insertions_s [String] insertions field from numstat output
220
+ #
221
+ # @param deletions_s [String] deletions field from numstat output
222
+ #
223
+ # @param include_binary [Boolean] whether to include the `:binary` key
224
+ #
225
+ # @return [Hash] stats hash with insertion and deletion totals
226
+ #
227
+ def build_stats(insertions_s, deletions_s, include_binary)
228
+ stats = { insertions: Diff.parse_stat_value(insertions_s),
229
+ deletions: Diff.parse_stat_value(deletions_s) }
230
+ stats[:binary] = (insertions_s == '-' && deletions_s == '-') if include_binary
231
+ stats
232
+ end
233
+
234
+ # Split output into numstat, shortstat, and dirstat sections
235
+ #
236
+ # @param lines [Array<String>] all output lines
237
+ #
238
+ # @param include_dirstat [Boolean] whether to expect dirstat section
239
+ #
240
+ # @return [Array] [numstat_lines, shortstat_line, dirstat_lines]
241
+ #
242
+ def split_sections(lines, include_dirstat)
243
+ shortstat_index = lines.index { |l| l.match?(/^\s*\d+\s+files?\s+changed/) }
244
+ return [lines, nil, []] unless shortstat_index
245
+
246
+ [lines[0...shortstat_index], lines[shortstat_index],
247
+ include_dirstat ? lines[(shortstat_index + 1)..] : []]
248
+ end
249
+
250
+ # Parse potential rename path into [dst_path, src_path]
251
+ #
252
+ # @param filename [String] the path string from numstat output
253
+ #
254
+ # @return [Array(String, (String, nil))] [destination_path, source_path_or_nil]
255
+ #
256
+ def parse_rename_path(filename)
257
+ if (match = filename.match(BRACE_RENAME_PATTERN))
258
+ prefix, old_part, new_part, suffix = match.captures
259
+ ["#{prefix}#{new_part}#{suffix}", "#{prefix}#{old_part}#{suffix}"]
260
+ elsif (match = filename.match(RENAME_PATTERN))
261
+ [match[2], match[1]]
262
+ else
263
+ [filename, nil]
264
+ end
265
+ end
266
+ end
267
+
268
+ # Parser for --raw output (combined with numstat)
269
+ module Raw
270
+ module_function
271
+
272
+ # Parse combined raw + numstat + shortstat output into DiffResult
273
+ #
274
+ # @param output [String] combined output
275
+ #
276
+ # @param include_dirstat [Boolean] whether dirstat output is expected
277
+ #
278
+ # @return [Git::DiffResult]
279
+ #
280
+ def parse(output, include_dirstat: false)
281
+ raw_lines, numstat_lines, shortstat_line, dirstat_lines = split_sections(output, include_dirstat)
282
+ numstat_map = Numstat.parse_as_map(numstat_lines, include_binary: true)
283
+
284
+ Diff.build_result(
285
+ files: raw_lines.map { |line| parse_raw_line(line, numstat_map) },
286
+ shortstat: Diff.parse_shortstat(shortstat_line),
287
+ dirstat: include_dirstat ? Diff.parse_dirstat(dirstat_lines) : nil
288
+ )
289
+ end
290
+
291
+ # Split output into raw, numstat, shortstat, and dirstat sections
292
+ #
293
+ # @param output [String] combined output
294
+ #
295
+ # @param include_dirstat [Boolean] whether to expect dirstat section
296
+ #
297
+ # @return [Array(Array<String>, Array<String>, (String, nil), Array<String>)]
298
+ #
299
+ def split_sections(output, include_dirstat)
300
+ lines = output.split("\n").reject(&:empty?)
301
+ raw_lines, non_raw_lines = lines.partition { |l| l.start_with?(':') }
302
+ shortstat_index = non_raw_lines.index { |l| l.match?(/^\s*\d+\s+files?\s+changed/) }
303
+
304
+ return [raw_lines, non_raw_lines, nil, []] unless shortstat_index
305
+
306
+ [raw_lines, non_raw_lines[0...shortstat_index], non_raw_lines[shortstat_index],
307
+ include_dirstat ? non_raw_lines[(shortstat_index + 1)..] : []]
308
+ end
309
+
310
+ # Parse a single --raw output line
311
+ #
312
+ # @param line [String] a single raw output line
313
+ #
314
+ # @param numstat_map [Hash<String, Hash>] path to stats mapping
315
+ #
316
+ # @return [Git::DiffFileRawInfo]
317
+ #
318
+ def parse_raw_line(line, numstat_map)
319
+ parsed = parse_raw_line_parts(line)
320
+ stats = numstat_map.fetch(parsed[:dst_path] || parsed[:src_path],
321
+ { insertions: 0, deletions: 0, binary: false })
322
+ build_raw_info(parsed, stats)
323
+ end
324
+
325
+ # Parses the fields from a raw diff line
326
+ #
327
+ # @param line [String] a single raw output line
328
+ #
329
+ # @return [Hash] parsed metadata for one raw diff entry
330
+ #
331
+ def parse_raw_line_parts(line)
332
+ parts = line[1..].split(/\s+/, 5)
333
+ status_char, *paths = parts[4].split("\t")
334
+ status, similarity = parse_status(status_char)
335
+ src_path, dst_path = extract_paths(paths)
336
+ { modes: parts[0..1], shas: parts[2..3], status: status,
337
+ similarity: similarity, src_path: src_path, dst_path: dst_path }
338
+ end
339
+
340
+ # Builds a raw diff file info object from parsed metadata and stats
341
+ #
342
+ # @param parsed [Hash] parsed metadata from {#parse_raw_line_parts}
343
+ #
344
+ # @param stats [Hash] insertion, deletion, and binary information
345
+ #
346
+ # @return [Git::DiffFileRawInfo]
347
+ #
348
+ def build_raw_info(parsed, stats)
349
+ Git::DiffFileRawInfo.new(
350
+ src: build_file_ref(parsed[:modes][0], parsed[:shas][0], parsed[:src_path]),
351
+ dst: build_file_ref(parsed[:modes][1], parsed[:shas][1], parsed[:dst_path]),
352
+ status: parsed[:status], similarity: parsed[:similarity], **stats
353
+ )
354
+ end
355
+
356
+ # Parse status character and optional similarity percentage
357
+ #
358
+ # @param status_char [String] e.g., 'M', 'A', 'R075'
359
+ #
360
+ # @return [Array(Symbol, (Integer, nil))] [status, similarity]
361
+ #
362
+ def parse_status(status_char)
363
+ letter = status_char[0]
364
+ similarity = status_char.length > 1 ? status_char[1..].to_i : nil
365
+ [STATUS_MAP.fetch(letter, :unknown), similarity]
366
+ end
367
+
368
+ # Extract source and destination paths from raw output paths
369
+ #
370
+ # @param paths [Array<String>] paths array
371
+ #
372
+ # @return [Array((String, nil), (String, nil))] [src_path, dst_path]
373
+ #
374
+ def extract_paths(paths)
375
+ if paths.length == 2
376
+ [Diff.unescape_path(paths[0]), Diff.unescape_path(paths[1])]
377
+ else
378
+ path = Diff.unescape_path(paths[0])
379
+ [path, path]
380
+ end
381
+ end
382
+
383
+ # Build a FileRef, returning nil if the file doesn't exist on this side
384
+ #
385
+ # @param mode [String] file mode
386
+ #
387
+ # @param sha [String] file SHA
388
+ #
389
+ # @param path [String, nil] file path
390
+ #
391
+ # @return [Git::FileRef, nil]
392
+ #
393
+ def build_file_ref(mode, sha, path)
394
+ return nil if mode == NULL_MODE || path.nil?
395
+
396
+ Git::FileRef.new(mode: mode, sha: sha, path: path)
397
+ end
398
+ end
399
+
400
+ # Parser for --patch output (combined with numstat)
401
+ module Patch
402
+ # Header line pattern for each patch file section
403
+ DIFF_HEADER_PATTERN = %r{\Adiff --git ("?)a/(.+?)\1 ("?)b/(.+?)\3\z}
404
+
405
+ # Index line pattern with source/destination SHAs and optional mode
406
+ INDEX_PATTERN = /^index ([0-9a-f]{4,40})\.\.([0-9a-f]{4,40})( ......)?/
407
+
408
+ # File mode line pattern for file creation and deletion
409
+ FILE_MODE_PATTERN = /^(new|deleted) file mode (......)/
410
+
411
+ # Old mode line pattern for mode changes
412
+ OLD_MODE_PATTERN = /^old mode (......)/
413
+
414
+ # New mode line pattern for mode changes
415
+ NEW_MODE_PATTERN = /^new mode (......)/
416
+
417
+ # Binary patch marker line pattern
418
+ BINARY_PATTERN = /^Binary files /
419
+
420
+ # Git binary patch header line pattern
421
+ GIT_BINARY_PATCH_PATTERN = /^GIT binary patch$/
422
+
423
+ # Rename source path line pattern
424
+ RENAME_FROM_PATTERN = /^rename from (.+)$/
425
+
426
+ # Rename destination path line pattern
427
+ RENAME_TO_PATTERN = /^rename to (.+)$/
428
+
429
+ # Copy source path line pattern
430
+ COPY_FROM_PATTERN = /^copy from (.+)$/
431
+
432
+ # Copy destination path line pattern
433
+ COPY_TO_PATTERN = /^copy to (.+)$/
434
+
435
+ # Similarity percentage line pattern
436
+ SIMILARITY_PATTERN = /^similarity index (\d+)%$/
437
+
438
+ # Maps patch metadata status words to diff status symbols
439
+ PATCH_STATUS_MAP = {
440
+ 'new' => :added,
441
+ 'deleted' => :deleted,
442
+ 'modified' => :modified,
443
+ 'renamed' => :renamed,
444
+ 'copied' => :copied,
445
+ 'type_changed' => :type_changed
446
+ }.freeze
447
+
448
+ module_function
449
+
450
+ # Parse combined patch + numstat + shortstat output into DiffResult
451
+ #
452
+ # @param output [String] combined output
453
+ #
454
+ # @param include_dirstat [Boolean] whether dirstat output is expected
455
+ #
456
+ # @return [Git::DiffResult]
457
+ #
458
+ def parse(output, include_dirstat: false)
459
+ return Diff.empty_result if output.empty?
460
+
461
+ numstat_lines, shortstat_line, dirstat_lines, patch_text = split_sections(output, include_dirstat)
462
+ numstat_map = Numstat.parse_as_map(numstat_lines)
463
+
464
+ Diff.build_result(
465
+ files: PatchFileParser.new(patch_text, numstat_map).parse,
466
+ shortstat: Diff.parse_shortstat(shortstat_line),
467
+ dirstat: include_dirstat ? Diff.parse_dirstat(dirstat_lines) : nil
468
+ )
469
+ end
470
+
471
+ # Split output into numstat, shortstat, dirstat, and patch sections
472
+ #
473
+ # @param output [String] combined output
474
+ #
475
+ # @param include_dirstat [Boolean] whether to expect dirstat section
476
+ #
477
+ # @return [Array(Array<String>, (String, nil), Array<String>, String)]
478
+ #
479
+ def split_sections(output, include_dirstat)
480
+ lines = output.lines
481
+ first_diff_index = lines.index { |l| l.start_with?('diff --git') } || lines.length
482
+ pre_diff_lines = lines[0...first_diff_index].map(&:chomp).reject(&:empty?)
483
+ patch_text = lines[first_diff_index..].join
484
+ split_pre_diff(pre_diff_lines, include_dirstat, patch_text)
485
+ end
486
+
487
+ # Splits pre-patch lines into numstat, shortstat, and dirstat sections
488
+ #
489
+ # @param pre_diff_lines [Array<String>] lines before the first diff header
490
+ #
491
+ # @param include_dirstat [Boolean] whether dirstat output is expected
492
+ #
493
+ # @param patch_text [String] patch text starting at the first diff header
494
+ #
495
+ # @return [Array(Array<String>, (String, nil), Array<String>, String)]
496
+ #
497
+ def split_pre_diff(pre_diff_lines, include_dirstat, patch_text)
498
+ shortstat_index = pre_diff_lines.index { |l| l.match?(/^\s*\d+\s+files?\s+changed/) }
499
+ return [pre_diff_lines, nil, [], patch_text] unless shortstat_index
500
+
501
+ [pre_diff_lines[0...shortstat_index], pre_diff_lines[shortstat_index],
502
+ include_dirstat ? pre_diff_lines[(shortstat_index + 1)..] : [], patch_text]
503
+ end
504
+
505
+ # Methods for parsing patch metadata lines (index, mode, rename, etc.)
506
+ #
507
+ # @api private
508
+ module PatchMetadataParser
509
+ private
510
+
511
+ # Parses a metadata line for the current patch file
512
+ #
513
+ # @param line [String] a metadata line from the patch block
514
+ #
515
+ # @return [void]
516
+ #
517
+ def parse_metadata_line(line)
518
+ try_parse_index(line)
519
+ try_parse_file_mode(line)
520
+ try_parse_old_new_mode(line)
521
+ try_parse_rename(line)
522
+ try_parse_similarity(line)
523
+ try_mark_binary(line)
524
+ end
525
+
526
+ # Parses index metadata and updates mode defaults when present
527
+ #
528
+ # @param line [String] a metadata line from the patch block
529
+ #
530
+ # @return [void]
531
+ #
532
+ def try_parse_index(line)
533
+ return unless (match = line.match(INDEX_PATTERN))
534
+
535
+ @current_file[:src_sha] = match[1]
536
+ @current_file[:dst_sha] = match[2]
537
+ return unless (mode = match[3]&.strip)
538
+ return unless @current_file[:src_mode].nil? && @current_file[:dst_mode].nil?
539
+
540
+ @current_file[:src_mode] = @current_file[:dst_mode] = mode
541
+ end
542
+
543
+ # Parses file creation and deletion mode lines
544
+ #
545
+ # @param line [String] a metadata line from the patch block
546
+ #
547
+ # @return [void]
548
+ #
549
+ def try_parse_file_mode(line)
550
+ return unless (match = line.match(FILE_MODE_PATTERN))
551
+
552
+ type, mode = match.captures
553
+ @current_file[:status] = PATCH_STATUS_MAP.fetch(type, :modified)
554
+ apply_file_mode(type, mode)
555
+ end
556
+
557
+ # Parses old/new mode lines and detects type changes
558
+ #
559
+ # @param line [String] a metadata line from the patch block
560
+ #
561
+ # @return [void]
562
+ #
563
+ def try_parse_old_new_mode(line)
564
+ if (match = line.match(OLD_MODE_PATTERN))
565
+ @current_file[:src_mode] = match[1]
566
+ detect_type_change
567
+ elsif (match = line.match(NEW_MODE_PATTERN))
568
+ @current_file[:dst_mode] = match[1]
569
+ detect_type_change
570
+ end
571
+ end
572
+
573
+ # Marks the current entry as a type change when type bits differ
574
+ #
575
+ # @return [void]
576
+ #
577
+ def detect_type_change
578
+ src_mode = @current_file[:src_mode]
579
+ dst_mode = @current_file[:dst_mode]
580
+ return unless src_mode && dst_mode
581
+
582
+ # Type change occurs when the file type bits differ (e.g., 100644 vs 120000)
583
+ # The first 3 digits represent the file type
584
+ @current_file[:status] = :type_changed if src_mode[0, 3] != dst_mode[0, 3]
585
+ end
586
+
587
+ # Applies source or destination mode updates based on file operation type
588
+ #
589
+ # @param type [String] operation type (`'new'` or `'deleted'`)
590
+ #
591
+ # @param mode [String] file mode from patch metadata
592
+ #
593
+ # @return [void]
594
+ #
595
+ def apply_file_mode(type, mode)
596
+ if type == 'new'
597
+ @current_file[:dst_mode] = mode
598
+ @current_file[:src_path] = nil
599
+ else
600
+ @current_file[:src_mode] = mode
601
+ @current_file[:dst_path] = nil
602
+ end
603
+ end
604
+
605
+ # Parses rename or copy metadata for the current patch file
606
+ #
607
+ # @param line [String] a metadata line from the patch block
608
+ #
609
+ # @return [Symbol, nil] parsed status when a rename or copy line matches
610
+ #
611
+ def try_parse_rename(line)
612
+ try_parse_rename_or_copy(line, RENAME_FROM_PATTERN, RENAME_TO_PATTERN, :renamed) ||
613
+ try_parse_rename_or_copy(line, COPY_FROM_PATTERN, COPY_TO_PATTERN, :copied)
614
+ end
615
+
616
+ # Parses one side of rename or copy metadata
617
+ #
618
+ # @param line [String] a metadata line from the patch block
619
+ #
620
+ # @param from_pattern [Regexp] pattern for source path lines
621
+ #
622
+ # @param to_pattern [Regexp] pattern for destination path lines
623
+ #
624
+ # @param status [Symbol] status to set when a pattern matches
625
+ #
626
+ # @return [Symbol, nil] the assigned status when matched
627
+ #
628
+ def try_parse_rename_or_copy(line, from_pattern, to_pattern, status)
629
+ if (match = line.match(from_pattern))
630
+ @current_file[:src_path] = Diff.unescape_path(match[1])
631
+ @current_file[:status] = status
632
+ elsif (match = line.match(to_pattern))
633
+ @current_file[:dst_path] = Diff.unescape_path(match[1])
634
+ @current_file[:status] = status
635
+ end
636
+ end
637
+
638
+ # Parses similarity index metadata
639
+ #
640
+ # @param line [String] a metadata line from the patch block
641
+ #
642
+ # @return [Integer, nil] parsed similarity percentage when matched
643
+ #
644
+ def try_parse_similarity(line)
645
+ return unless (match = line.match(SIMILARITY_PATTERN))
646
+
647
+ @current_file[:similarity] = match[1].to_i
648
+ end
649
+
650
+ # Marks the current file as binary when binary markers are present
651
+ #
652
+ # @param line [String] a metadata line from the patch block
653
+ #
654
+ # @return [Boolean, nil] true when binary metadata is detected
655
+ #
656
+ def try_mark_binary(line)
657
+ @current_file[:binary] = true if line.match?(BINARY_PATTERN) || line.match?(GIT_BINARY_PATCH_PATTERN)
658
+ end
659
+ end
660
+
661
+ # Stateful parser for unified diff patch output
662
+ #
663
+ # @api private
664
+ class PatchFileParser
665
+ include PatchMetadataParser
666
+
667
+ # Initializes parser state for a patch text block
668
+ #
669
+ # @param patch_text [String] unified diff patch text
670
+ #
671
+ # @param numstat_map [Hash<String, Hash>] path to insert/delete stats
672
+ #
673
+ # @return [void]
674
+ #
675
+ def initialize(patch_text, numstat_map = {})
676
+ @patch_text = patch_text
677
+ @numstat_map = numstat_map
678
+ @files = []
679
+ @current_file = nil
680
+ end
681
+
682
+ # Parses patch text into file patch info objects
683
+ #
684
+ # @return [Array<Git::DiffFilePatchInfo>]
685
+ #
686
+ def parse
687
+ @patch_text.split("\n").each { |line| process_line(line) }
688
+ finalize_current_file
689
+ @files
690
+ end
691
+
692
+ private
693
+
694
+ # Processes one patch line and updates parser state
695
+ #
696
+ # @param line [String] a single line from patch text
697
+ #
698
+ # @return [void]
699
+ #
700
+ def process_line(line)
701
+ if (match = line.match(DIFF_HEADER_PATTERN))
702
+ start_new_file(match, line)
703
+ elsif @current_file
704
+ append_to_current_file(line)
705
+ end
706
+ end
707
+
708
+ # Starts a new file section from a `diff --git` header
709
+ #
710
+ # @param match [MatchData] parsed header match for current diff file
711
+ #
712
+ # @param line [String] raw `diff --git` header line
713
+ #
714
+ # @return [void]
715
+ #
716
+ def start_new_file(match, line)
717
+ finalize_current_file
718
+ @current_file = default_file_state.merge(
719
+ patch: line,
720
+ src_path: Git::EscapedPath.new(match[2]).unescape,
721
+ dst_path: Git::EscapedPath.new(match[4]).unescape
722
+ )
723
+ end
724
+
725
+ # Builds the default state hash for a new patch file entry
726
+ #
727
+ # @return [Hash] default metadata for an in-progress file parse
728
+ #
729
+ def default_file_state
730
+ { src_mode: nil, dst_mode: nil, src_sha: '', dst_sha: '',
731
+ src_path: nil, dst_path: nil, status: :modified, similarity: nil, binary: false }
732
+ end
733
+
734
+ # Appends metadata and patch text to the current file entry
735
+ #
736
+ # @param line [String] a metadata or patch content line
737
+ #
738
+ # @return [void]
739
+ #
740
+ def append_to_current_file(line)
741
+ parse_metadata_line(line)
742
+ @current_file[:patch] = "#{@current_file[:patch]}\n#{line}"
743
+ end
744
+
745
+ # Finalizes and stores the current file entry
746
+ #
747
+ # @return [void]
748
+ #
749
+ def finalize_current_file
750
+ return unless @current_file
751
+
752
+ @files << build_patch_info
753
+ @current_file = nil
754
+ end
755
+
756
+ # Builds a finalized patch info object for the current file
757
+ #
758
+ # @return [Git::DiffFilePatchInfo]
759
+ #
760
+ def build_patch_info
761
+ path = @current_file[:dst_path] || @current_file[:src_path]
762
+ stats = @numstat_map.fetch(path, { insertions: 0, deletions: 0 })
763
+
764
+ Git::DiffFilePatchInfo.new(
765
+ src: build_file_ref(:src), dst: build_file_ref(:dst),
766
+ patch: @current_file[:patch], status: @current_file[:status],
767
+ similarity: @current_file[:similarity], binary: @current_file[:binary],
768
+ insertions: stats[:insertions], deletions: stats[:deletions]
769
+ )
770
+ end
771
+
772
+ # Builds a file reference for one side of the diff entry
773
+ #
774
+ # @param side [Symbol] diff side key (`:src` or `:dst`)
775
+ #
776
+ # @return [Git::FileRef, nil]
777
+ #
778
+ def build_file_ref(side)
779
+ path = @current_file[:"#{side}_path"]
780
+ return nil if path.nil?
781
+
782
+ Git::FileRef.new(
783
+ mode: @current_file[:"#{side}_mode"] || '',
784
+ sha: @current_file[:"#{side}_sha"] || '',
785
+ path: path
786
+ )
787
+ end
788
+ end
789
+ end
790
+ end
791
+ end
792
+ end