git 4.3.2 → 5.0.0.beta.1

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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.github/copilot-instructions.md +67 -2705
  3. data/.github/pull_request_template.md +3 -1
  4. data/.github/skills/breaking-change-analysis/SKILL.md +102 -0
  5. data/.github/skills/ci-cd-troubleshooting/SKILL.md +264 -0
  6. data/.github/skills/command-implementation/REFERENCE.md +993 -0
  7. data/.github/skills/command-implementation/SKILL.md +229 -0
  8. data/.github/skills/command-test-conventions/SKILL.md +660 -0
  9. data/.github/skills/command-yard-documentation/SKILL.md +426 -0
  10. data/.github/skills/dependency-management/SKILL.md +72 -0
  11. data/.github/skills/development-workflow/SKILL.md +506 -0
  12. data/.github/skills/extract-command-from-lib/SKILL.md +487 -0
  13. data/.github/skills/extract-facade-from-base-lib/SKILL.md +586 -0
  14. data/.github/skills/facade-implementation/REFERENCE.md +840 -0
  15. data/.github/skills/facade-implementation/SKILL.md +260 -0
  16. data/.github/skills/facade-test-conventions/SKILL.md +380 -0
  17. data/.github/skills/facade-yard-documentation/SKILL.md +429 -0
  18. data/.github/skills/make-skill-template/SKILL.md +176 -0
  19. data/.github/skills/pr-readiness-review/SKILL.md +185 -0
  20. data/.github/skills/project-context/SKILL.md +313 -0
  21. data/.github/skills/pull-request-review/SKILL.md +168 -0
  22. data/.github/skills/refactor-command-to-commandlineresult/SKILL.md +131 -0
  23. data/.github/skills/release-management/SKILL.md +125 -0
  24. data/.github/skills/review-arguments-dsl/CHECKLIST.md +788 -0
  25. data/.github/skills/review-arguments-dsl/SKILL.md +214 -0
  26. data/.github/skills/review-backward-compatibility/SKILL.md +275 -0
  27. data/.github/skills/review-cross-command-consistency/SKILL.md +139 -0
  28. data/.github/skills/reviewing-skills/SKILL.md +189 -0
  29. data/.github/skills/rspec-unit-testing-standards/SKILL.md +639 -0
  30. data/.github/skills/tdd-refactor-step/SKILL.md +236 -0
  31. data/.github/skills/test-debugging/SKILL.md +160 -0
  32. data/.github/skills/yard-documentation/SKILL.md +793 -0
  33. data/.github/workflows/continuous_integration.yml +3 -2
  34. data/.github/workflows/enforce_conventional_commits.yml +1 -1
  35. data/.github/workflows/experimental_continuous_integration.yml +2 -2
  36. data/.github/workflows/release.yml +3 -4
  37. data/.gitignore +8 -0
  38. data/.husky/pre-commit +13 -0
  39. data/.release-please-manifest.json +1 -1
  40. data/.rspec +3 -0
  41. data/.rubocop.yml +7 -3
  42. data/.rubocop_todo.yml +23 -5
  43. data/.yardopts +1 -0
  44. data/CHANGELOG.md +0 -40
  45. data/CONTRIBUTING.md +694 -53
  46. data/README.md +17 -5
  47. data/Rakefile +61 -9
  48. data/commitlint.test +4 -0
  49. data/git.gemspec +14 -8
  50. data/lib/git/args_builder.rb +0 -8
  51. data/lib/git/base.rb +486 -410
  52. data/lib/git/branch.rb +380 -43
  53. data/lib/git/branch_delete_failure.rb +31 -0
  54. data/lib/git/branch_delete_result.rb +63 -0
  55. data/lib/git/branch_info.rb +178 -0
  56. data/lib/git/branches.rb +130 -24
  57. data/lib/git/command_line/base.rb +245 -0
  58. data/lib/git/command_line/capturing.rb +249 -0
  59. data/lib/git/command_line/result.rb +96 -0
  60. data/lib/git/command_line/streaming.rb +194 -0
  61. data/lib/git/command_line.rb +43 -322
  62. data/lib/git/command_line_result.rb +4 -88
  63. data/lib/git/commands/add.rb +131 -0
  64. data/lib/git/commands/am/abort.rb +43 -0
  65. data/lib/git/commands/am/apply.rb +252 -0
  66. data/lib/git/commands/am/continue.rb +43 -0
  67. data/lib/git/commands/am/quit.rb +43 -0
  68. data/lib/git/commands/am/retry.rb +47 -0
  69. data/lib/git/commands/am/show_current_patch.rb +64 -0
  70. data/lib/git/commands/am/skip.rb +42 -0
  71. data/lib/git/commands/am.rb +33 -0
  72. data/lib/git/commands/apply.rb +237 -0
  73. data/lib/git/commands/archive/list_formats.rb +46 -0
  74. data/lib/git/commands/archive.rb +140 -0
  75. data/lib/git/commands/arguments.rb +3510 -0
  76. data/lib/git/commands/base.rb +403 -0
  77. data/lib/git/commands/branch/copy.rb +94 -0
  78. data/lib/git/commands/branch/create.rb +173 -0
  79. data/lib/git/commands/branch/delete.rb +80 -0
  80. data/lib/git/commands/branch/list.rb +162 -0
  81. data/lib/git/commands/branch/move.rb +94 -0
  82. data/lib/git/commands/branch/set_upstream.rb +86 -0
  83. data/lib/git/commands/branch/show_current.rb +49 -0
  84. data/lib/git/commands/branch/unset_upstream.rb +57 -0
  85. data/lib/git/commands/branch.rb +34 -0
  86. data/lib/git/commands/cat_file/batch.rb +364 -0
  87. data/lib/git/commands/cat_file/filtered.rb +105 -0
  88. data/lib/git/commands/cat_file/raw.rb +210 -0
  89. data/lib/git/commands/cat_file.rb +49 -0
  90. data/lib/git/commands/checkout/branch.rb +151 -0
  91. data/lib/git/commands/checkout/files.rb +115 -0
  92. data/lib/git/commands/checkout.rb +38 -0
  93. data/lib/git/commands/checkout_index.rb +105 -0
  94. data/lib/git/commands/clean.rb +100 -0
  95. data/lib/git/commands/clone.rb +240 -0
  96. data/lib/git/commands/commit.rb +272 -0
  97. data/lib/git/commands/commit_tree.rb +100 -0
  98. data/lib/git/commands/config_option_syntax/add.rb +83 -0
  99. data/lib/git/commands/config_option_syntax/get.rb +117 -0
  100. data/lib/git/commands/config_option_syntax/get_all.rb +115 -0
  101. data/lib/git/commands/config_option_syntax/get_color.rb +91 -0
  102. data/lib/git/commands/config_option_syntax/get_color_bool.rb +93 -0
  103. data/lib/git/commands/config_option_syntax/get_regexp.rb +115 -0
  104. data/lib/git/commands/config_option_syntax/get_urlmatch.rb +102 -0
  105. data/lib/git/commands/config_option_syntax/list.rb +107 -0
  106. data/lib/git/commands/config_option_syntax/remove_section.rb +74 -0
  107. data/lib/git/commands/config_option_syntax/rename_section.rb +78 -0
  108. data/lib/git/commands/config_option_syntax/replace_all.rb +104 -0
  109. data/lib/git/commands/config_option_syntax/set.rb +114 -0
  110. data/lib/git/commands/config_option_syntax/unset.rb +89 -0
  111. data/lib/git/commands/config_option_syntax/unset_all.rb +89 -0
  112. data/lib/git/commands/config_option_syntax.rb +56 -0
  113. data/lib/git/commands/describe.rb +155 -0
  114. data/lib/git/commands/diff.rb +656 -0
  115. data/lib/git/commands/diff_files.rb +518 -0
  116. data/lib/git/commands/diff_index.rb +496 -0
  117. data/lib/git/commands/fetch.rb +352 -0
  118. data/lib/git/commands/fsck.rb +136 -0
  119. data/lib/git/commands/gc.rb +132 -0
  120. data/lib/git/commands/grep.rb +338 -0
  121. data/lib/git/commands/init.rb +99 -0
  122. data/lib/git/commands/log.rb +632 -0
  123. data/lib/git/commands/ls_files.rb +191 -0
  124. data/lib/git/commands/ls_remote.rb +155 -0
  125. data/lib/git/commands/ls_tree.rb +131 -0
  126. data/lib/git/commands/maintenance/register.rb +75 -0
  127. data/lib/git/commands/maintenance/run.rb +104 -0
  128. data/lib/git/commands/maintenance/start.rb +66 -0
  129. data/lib/git/commands/maintenance/stop.rb +55 -0
  130. data/lib/git/commands/maintenance/unregister.rb +79 -0
  131. data/lib/git/commands/maintenance.rb +31 -0
  132. data/lib/git/commands/merge/abort.rb +44 -0
  133. data/lib/git/commands/merge/continue.rb +44 -0
  134. data/lib/git/commands/merge/quit.rb +46 -0
  135. data/lib/git/commands/merge/start.rb +245 -0
  136. data/lib/git/commands/merge.rb +28 -0
  137. data/lib/git/commands/merge_base.rb +86 -0
  138. data/lib/git/commands/mv.rb +77 -0
  139. data/lib/git/commands/name_rev.rb +114 -0
  140. data/lib/git/commands/pull.rb +377 -0
  141. data/lib/git/commands/push.rb +246 -0
  142. data/lib/git/commands/read_tree.rb +149 -0
  143. data/lib/git/commands/remote/add.rb +91 -0
  144. data/lib/git/commands/remote/get_url.rb +66 -0
  145. data/lib/git/commands/remote/list.rb +54 -0
  146. data/lib/git/commands/remote/prune.rb +61 -0
  147. data/lib/git/commands/remote/remove.rb +52 -0
  148. data/lib/git/commands/remote/rename.rb +69 -0
  149. data/lib/git/commands/remote/set_branches.rb +63 -0
  150. data/lib/git/commands/remote/set_head.rb +82 -0
  151. data/lib/git/commands/remote/set_url.rb +71 -0
  152. data/lib/git/commands/remote/set_url_add.rb +61 -0
  153. data/lib/git/commands/remote/set_url_delete.rb +64 -0
  154. data/lib/git/commands/remote/show.rb +71 -0
  155. data/lib/git/commands/remote/update.rb +72 -0
  156. data/lib/git/commands/remote.rb +42 -0
  157. data/lib/git/commands/repack.rb +277 -0
  158. data/lib/git/commands/reset.rb +147 -0
  159. data/lib/git/commands/rev_parse.rb +297 -0
  160. data/lib/git/commands/revert/abort.rb +45 -0
  161. data/lib/git/commands/revert/continue.rb +57 -0
  162. data/lib/git/commands/revert/quit.rb +47 -0
  163. data/lib/git/commands/revert/skip.rb +44 -0
  164. data/lib/git/commands/revert/start.rb +153 -0
  165. data/lib/git/commands/revert.rb +29 -0
  166. data/lib/git/commands/rm.rb +114 -0
  167. data/lib/git/commands/show.rb +632 -0
  168. data/lib/git/commands/show_ref/exclude_existing.rb +120 -0
  169. data/lib/git/commands/show_ref/exists.rb +78 -0
  170. data/lib/git/commands/show_ref/list.rb +145 -0
  171. data/lib/git/commands/show_ref/verify.rb +120 -0
  172. data/lib/git/commands/show_ref.rb +42 -0
  173. data/lib/git/commands/stash/apply.rb +75 -0
  174. data/lib/git/commands/stash/branch.rb +65 -0
  175. data/lib/git/commands/stash/clear.rb +41 -0
  176. data/lib/git/commands/stash/create.rb +58 -0
  177. data/lib/git/commands/stash/drop.rb +67 -0
  178. data/lib/git/commands/stash/list.rb +39 -0
  179. data/lib/git/commands/stash/pop.rb +78 -0
  180. data/lib/git/commands/stash/push.rb +103 -0
  181. data/lib/git/commands/stash/show.rb +149 -0
  182. data/lib/git/commands/stash/store.rb +63 -0
  183. data/lib/git/commands/stash.rb +38 -0
  184. data/lib/git/commands/status.rb +169 -0
  185. data/lib/git/commands/symbolic_ref/delete.rb +68 -0
  186. data/lib/git/commands/symbolic_ref/read.rb +95 -0
  187. data/lib/git/commands/symbolic_ref/update.rb +76 -0
  188. data/lib/git/commands/symbolic_ref.rb +38 -0
  189. data/lib/git/commands/tag/create.rb +139 -0
  190. data/lib/git/commands/tag/delete.rb +55 -0
  191. data/lib/git/commands/tag/list.rb +143 -0
  192. data/lib/git/commands/tag/verify.rb +71 -0
  193. data/lib/git/commands/tag.rb +26 -0
  194. data/lib/git/commands/update_ref/batch.rb +140 -0
  195. data/lib/git/commands/update_ref/delete.rb +92 -0
  196. data/lib/git/commands/update_ref/update.rb +106 -0
  197. data/lib/git/commands/update_ref.rb +42 -0
  198. data/lib/git/commands/version.rb +52 -0
  199. data/lib/git/commands/worktree/add.rb +140 -0
  200. data/lib/git/commands/worktree/list.rb +64 -0
  201. data/lib/git/commands/worktree/lock.rb +58 -0
  202. data/lib/git/commands/worktree/management_base.rb +51 -0
  203. data/lib/git/commands/worktree/move.rb +66 -0
  204. data/lib/git/commands/worktree/prune.rb +67 -0
  205. data/lib/git/commands/worktree/remove.rb +63 -0
  206. data/lib/git/commands/worktree/repair.rb +76 -0
  207. data/lib/git/commands/worktree/unlock.rb +47 -0
  208. data/lib/git/commands/worktree.rb +43 -0
  209. data/lib/git/commands/write_tree.rb +68 -0
  210. data/lib/git/commands.rb +89 -0
  211. data/lib/git/detached_head_info.rb +54 -0
  212. data/lib/git/diff.rb +297 -7
  213. data/lib/git/diff_file_numstat_info.rb +29 -0
  214. data/lib/git/diff_file_patch_info.rb +134 -0
  215. data/lib/git/diff_file_raw_info.rb +127 -0
  216. data/lib/git/diff_info.rb +169 -0
  217. data/lib/git/diff_path_status.rb +78 -19
  218. data/lib/git/diff_result.rb +32 -0
  219. data/lib/git/diff_stats.rb +59 -14
  220. data/lib/git/dirstat_info.rb +86 -0
  221. data/lib/git/errors.rb +65 -2
  222. data/lib/git/execution_context/global.rb +56 -0
  223. data/lib/git/execution_context/repository.rb +147 -0
  224. data/lib/git/execution_context.rb +482 -0
  225. data/lib/git/file_ref.rb +74 -0
  226. data/lib/git/fsck_object.rb +9 -9
  227. data/lib/git/fsck_result.rb +1 -1
  228. data/lib/git/lib.rb +1606 -1028
  229. data/lib/git/log.rb +15 -2
  230. data/lib/git/object.rb +92 -22
  231. data/lib/git/parsers/branch.rb +224 -0
  232. data/lib/git/parsers/cat_file.rb +111 -0
  233. data/lib/git/parsers/diff.rb +585 -0
  234. data/lib/git/parsers/fsck.rb +133 -0
  235. data/lib/git/parsers/grep.rb +42 -0
  236. data/lib/git/parsers/ls_tree.rb +58 -0
  237. data/lib/git/parsers/stash.rb +208 -0
  238. data/lib/git/parsers/tag.rb +257 -0
  239. data/lib/git/remote.rb +133 -9
  240. data/lib/git/repository/branching.rb +572 -0
  241. data/lib/git/repository/committing.rb +191 -0
  242. data/lib/git/repository/configuring.rb +156 -0
  243. data/lib/git/repository/diffing.rb +775 -0
  244. data/lib/git/repository/inspecting.rb +153 -0
  245. data/lib/git/repository/logging.rb +247 -0
  246. data/lib/git/repository/merging.rb +295 -0
  247. data/lib/git/repository/object_operations.rb +1101 -0
  248. data/lib/git/repository/path_resolver.rb +207 -0
  249. data/lib/git/repository/remote_operations.rb +753 -0
  250. data/lib/git/repository/shared_private.rb +51 -0
  251. data/lib/git/repository/staging.rb +390 -0
  252. data/lib/git/repository/stashing.rb +107 -0
  253. data/lib/git/repository/status_operations.rb +180 -0
  254. data/lib/git/repository/worktree_operations.rb +159 -0
  255. data/lib/git/repository.rb +264 -1
  256. data/lib/git/stash.rb +85 -4
  257. data/lib/git/stash_info.rb +104 -0
  258. data/lib/git/stashes.rb +130 -13
  259. data/lib/git/status.rb +224 -18
  260. data/lib/git/tag_delete_failure.rb +31 -0
  261. data/lib/git/tag_delete_result.rb +63 -0
  262. data/lib/git/tag_info.rb +105 -0
  263. data/lib/git/version.rb +109 -2
  264. data/lib/git/version_constraint.rb +81 -0
  265. data/lib/git/worktree.rb +120 -5
  266. data/lib/git/worktrees.rb +107 -7
  267. data/lib/git.rb +114 -18
  268. data/redesign/1_architecture_existing.md +54 -18
  269. data/redesign/2_architecture_redesign.md +365 -46
  270. data/redesign/3_architecture_implementation.md +1451 -54
  271. data/tasks/gem_tasks.rake +4 -0
  272. data/tasks/npm_tasks.rake +7 -0
  273. data/tasks/rspec.rake +48 -0
  274. data/tasks/test.rake +13 -1
  275. data/tasks/yard.rake +34 -7
  276. metadata +349 -20
  277. data/lib/git/index.rb +0 -6
  278. data/lib/git/path.rb +0 -38
  279. data/lib/git/working_directory.rb +0 -6
  280. /data/{release-please-config.json → .release-please-config.json} +0 -0
@@ -0,0 +1,585 @@
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
+ BRACE_RENAME_PATTERN = /\A(.*)\{(.+) => (.+)\}(.*)\z/
61
+
62
+ module_function
63
+
64
+ # Build a DiffResult from parsed components
65
+ #
66
+ # @param files [Array] array of file info objects
67
+ # @param shortstat [Hash] parsed shortstat data
68
+ # @param dirstat [Git::DirstatInfo, nil] parsed dirstat data
69
+ # @return [Git::DiffResult]
70
+ #
71
+ def build_result(files:, shortstat:, dirstat:)
72
+ Git::DiffResult.new(
73
+ files_changed: shortstat[:files_changed],
74
+ total_insertions: shortstat[:insertions],
75
+ total_deletions: shortstat[:deletions],
76
+ files: files,
77
+ dirstat: dirstat
78
+ )
79
+ end
80
+
81
+ # Build an empty DiffResult
82
+ #
83
+ # @return [Git::DiffResult]
84
+ #
85
+ def empty_result
86
+ Git::DiffResult.new(
87
+ files_changed: 0, total_insertions: 0, total_deletions: 0,
88
+ files: [], dirstat: nil
89
+ )
90
+ end
91
+
92
+ # Parse shortstat line into components
93
+ #
94
+ # @example
95
+ # parse_shortstat(" 3 files changed, 10 insertions(+), 5 deletions(-)")
96
+ # # => { files_changed: 3, insertions: 10, deletions: 5 }
97
+ #
98
+ # @param line [String, nil] the shortstat line
99
+ # @return [Hash] { files_changed:, insertions:, deletions: }
100
+ #
101
+ def parse_shortstat(line)
102
+ return { files_changed: 0, insertions: 0, deletions: 0 } if line.nil?
103
+
104
+ {
105
+ files_changed: line.match(/(\d+)\s+files?\s+changed/)&.[](1).to_i,
106
+ insertions: line.match(/(\d+)\s+insertions?\(\+\)/)&.[](1).to_i,
107
+ deletions: line.match(/(\d+)\s+deletions?\(-\)/)&.[](1).to_i
108
+ }
109
+ end
110
+
111
+ # Parse dirstat lines into DirstatInfo
112
+ #
113
+ # @example
114
+ # parse_dirstat([" 50.0% lib/", " 50.0% spec/"])
115
+ # # => #<Git::DirstatInfo entries: [...]>
116
+ #
117
+ # @param lines [Array<String>] dirstat output lines
118
+ # @return [Git::DirstatInfo]
119
+ #
120
+ def parse_dirstat(lines)
121
+ entries = lines.filter_map do |line|
122
+ next unless (match = line.match(/^\s*([\d.]+)%\s+(.+)$/))
123
+
124
+ Git::DirstatEntry.new(percentage: match[1].to_f, directory: match[2])
125
+ end
126
+ Git::DirstatInfo.new(entries: entries)
127
+ end
128
+
129
+ # Parse a stat value (handles '-' for binary files)
130
+ #
131
+ # @param value [String] the stat value string
132
+ # @return [Integer] the numeric value (0 for binary files)
133
+ #
134
+ def parse_stat_value(value)
135
+ value == '-' ? 0 : value.to_i
136
+ end
137
+
138
+ # Unescape quoted path from git output
139
+ #
140
+ # @param path [String] potentially quoted path
141
+ # @return [String] unescaped path
142
+ #
143
+ def unescape_path(path)
144
+ return path unless path&.start_with?('"') && path.end_with?('"')
145
+
146
+ Git::EscapedPath.new(path[1..-2]).unescape
147
+ end
148
+
149
+ # Parser for --numstat output
150
+ module Numstat
151
+ module_function
152
+
153
+ # Parse numstat output into DiffResult
154
+ #
155
+ # @param output [String] raw numstat + shortstat output
156
+ # @param include_dirstat [Boolean] whether dirstat output is expected
157
+ # @return [Git::DiffResult]
158
+ #
159
+ def parse(output, include_dirstat: false)
160
+ lines = output.split("\n").reject(&:empty?)
161
+ numstat_lines, shortstat_line, dirstat_lines = split_sections(lines, include_dirstat)
162
+
163
+ Diff.build_result(
164
+ files: parse_file_stats(numstat_lines),
165
+ shortstat: Diff.parse_shortstat(shortstat_line),
166
+ dirstat: include_dirstat ? Diff.parse_dirstat(dirstat_lines) : nil
167
+ )
168
+ end
169
+
170
+ # Parse numstat lines into DiffFileNumstatInfo array
171
+ #
172
+ # @param lines [Array<String>] numstat lines
173
+ # @return [Array<Git::DiffFileNumstatInfo>]
174
+ #
175
+ def parse_file_stats(lines)
176
+ lines.map do |line|
177
+ insertions_s, deletions_s, filename = line.split("\t", 3)
178
+ path, src_path = parse_rename_path(filename)
179
+ Git::DiffFileNumstatInfo.new(
180
+ path: Diff.unescape_path(path),
181
+ src_path: src_path ? Diff.unescape_path(src_path) : nil,
182
+ insertions: Diff.parse_stat_value(insertions_s),
183
+ deletions: Diff.parse_stat_value(deletions_s)
184
+ )
185
+ end
186
+ end
187
+
188
+ # Parse numstat lines into a path -> stats hash (for combining with other formats)
189
+ #
190
+ # @param lines [Array<String>] numstat lines
191
+ # @param include_binary [Boolean] whether to include binary flag
192
+ # @return [Hash<String, Hash>] path to stats mapping (keyed by destination path)
193
+ #
194
+ def parse_as_map(lines, include_binary: false)
195
+ lines.to_h do |line|
196
+ insertions_s, deletions_s, filename = line.split("\t", 3)
197
+ # Normalize rename paths so the key matches the dst_path used by raw/patch parsers
198
+ dst_path, _src_path = parse_rename_path(filename)
199
+ [Diff.unescape_path(dst_path), build_stats(insertions_s, deletions_s, include_binary)]
200
+ end
201
+ end
202
+
203
+ def build_stats(insertions_s, deletions_s, include_binary)
204
+ stats = { insertions: Diff.parse_stat_value(insertions_s),
205
+ deletions: Diff.parse_stat_value(deletions_s) }
206
+ stats[:binary] = (insertions_s == '-' && deletions_s == '-') if include_binary
207
+ stats
208
+ end
209
+
210
+ # Split output into numstat, shortstat, and dirstat sections
211
+ #
212
+ # @param lines [Array<String>] all output lines
213
+ # @param include_dirstat [Boolean] whether to expect dirstat section
214
+ # @return [Array] [numstat_lines, shortstat_line, dirstat_lines]
215
+ #
216
+ def split_sections(lines, include_dirstat)
217
+ shortstat_index = lines.index { |l| l.match?(/^\s*\d+\s+files?\s+changed/) }
218
+ return [lines, nil, []] unless shortstat_index
219
+
220
+ [lines[0...shortstat_index], lines[shortstat_index],
221
+ include_dirstat ? lines[(shortstat_index + 1)..] : []]
222
+ end
223
+
224
+ # Parse potential rename path into [dst_path, src_path]
225
+ #
226
+ # @param filename [String] the path string from numstat output
227
+ # @return [Array<String, String|nil>] [destination_path, source_path_or_nil]
228
+ #
229
+ def parse_rename_path(filename)
230
+ if (match = filename.match(BRACE_RENAME_PATTERN))
231
+ prefix, old_part, new_part, suffix = match.captures
232
+ ["#{prefix}#{new_part}#{suffix}", "#{prefix}#{old_part}#{suffix}"]
233
+ elsif (match = filename.match(RENAME_PATTERN))
234
+ [match[2], match[1]]
235
+ else
236
+ [filename, nil]
237
+ end
238
+ end
239
+ end
240
+
241
+ # Parser for --raw output (combined with numstat)
242
+ module Raw
243
+ module_function
244
+
245
+ # Parse combined raw + numstat + shortstat output into DiffResult
246
+ #
247
+ # @param output [String] combined output
248
+ # @param include_dirstat [Boolean] whether dirstat output is expected
249
+ # @return [Git::DiffResult]
250
+ #
251
+ def parse(output, include_dirstat: false)
252
+ raw_lines, numstat_lines, shortstat_line, dirstat_lines = split_sections(output, include_dirstat)
253
+ numstat_map = Numstat.parse_as_map(numstat_lines, include_binary: true)
254
+
255
+ Diff.build_result(
256
+ files: raw_lines.map { |line| parse_raw_line(line, numstat_map) },
257
+ shortstat: Diff.parse_shortstat(shortstat_line),
258
+ dirstat: include_dirstat ? Diff.parse_dirstat(dirstat_lines) : nil
259
+ )
260
+ end
261
+
262
+ # Split output into raw, numstat, shortstat, and dirstat sections
263
+ #
264
+ # @param output [String] combined output
265
+ # @param include_dirstat [Boolean] whether to expect dirstat section
266
+ # @return [Array<Array<String>, Array<String>, String|nil, Array<String>>]
267
+ #
268
+ def split_sections(output, include_dirstat)
269
+ lines = output.split("\n").reject(&:empty?)
270
+ raw_lines, non_raw_lines = lines.partition { |l| l.start_with?(':') }
271
+ shortstat_index = non_raw_lines.index { |l| l.match?(/^\s*\d+\s+files?\s+changed/) }
272
+
273
+ return [raw_lines, non_raw_lines, nil, []] unless shortstat_index
274
+
275
+ [raw_lines, non_raw_lines[0...shortstat_index], non_raw_lines[shortstat_index],
276
+ include_dirstat ? non_raw_lines[(shortstat_index + 1)..] : []]
277
+ end
278
+
279
+ # Parse a single --raw output line
280
+ #
281
+ # @param line [String] a single raw output line
282
+ # @param numstat_map [Hash<String, Hash>] path to stats mapping
283
+ # @return [Git::DiffFileRawInfo]
284
+ #
285
+ def parse_raw_line(line, numstat_map)
286
+ parsed = parse_raw_line_parts(line)
287
+ stats = numstat_map.fetch(parsed[:dst_path] || parsed[:src_path],
288
+ { insertions: 0, deletions: 0, binary: false })
289
+ build_raw_info(parsed, stats)
290
+ end
291
+
292
+ def parse_raw_line_parts(line)
293
+ parts = line[1..].split(/\s+/, 5)
294
+ status_char, *paths = parts[4].split("\t")
295
+ status, similarity = parse_status(status_char)
296
+ src_path, dst_path = extract_paths(paths)
297
+ { modes: parts[0..1], shas: parts[2..3], status: status,
298
+ similarity: similarity, src_path: src_path, dst_path: dst_path }
299
+ end
300
+
301
+ def build_raw_info(parsed, stats)
302
+ Git::DiffFileRawInfo.new(
303
+ src: build_file_ref(parsed[:modes][0], parsed[:shas][0], parsed[:src_path]),
304
+ dst: build_file_ref(parsed[:modes][1], parsed[:shas][1], parsed[:dst_path]),
305
+ status: parsed[:status], similarity: parsed[:similarity], **stats
306
+ )
307
+ end
308
+
309
+ # Parse status character and optional similarity percentage
310
+ #
311
+ # @param status_char [String] e.g., 'M', 'A', 'R075'
312
+ # @return [Array<Symbol, Integer|nil>] [status, similarity]
313
+ #
314
+ def parse_status(status_char)
315
+ letter = status_char[0]
316
+ similarity = status_char.length > 1 ? status_char[1..].to_i : nil
317
+ [STATUS_MAP.fetch(letter, :unknown), similarity]
318
+ end
319
+
320
+ # Extract source and destination paths from raw output paths
321
+ #
322
+ # @param paths [Array<String>] paths array
323
+ # @return [Array<String|nil, String|nil>] [src_path, dst_path]
324
+ #
325
+ def extract_paths(paths)
326
+ if paths.length == 2
327
+ [Diff.unescape_path(paths[0]), Diff.unescape_path(paths[1])]
328
+ else
329
+ path = Diff.unescape_path(paths[0])
330
+ [path, path]
331
+ end
332
+ end
333
+
334
+ # Build a FileRef, returning nil if the file doesn't exist on this side
335
+ #
336
+ # @param mode [String] file mode
337
+ # @param sha [String] file SHA
338
+ # @param path [String, nil] file path
339
+ # @return [Git::FileRef, nil]
340
+ #
341
+ def build_file_ref(mode, sha, path)
342
+ return nil if mode == NULL_MODE || path.nil?
343
+
344
+ Git::FileRef.new(mode: mode, sha: sha, path: path)
345
+ end
346
+ end
347
+
348
+ # Parser for --patch output (combined with numstat)
349
+ module Patch
350
+ DIFF_HEADER_PATTERN = %r{\Adiff --git ("?)a/(.+?)\1 ("?)b/(.+?)\3\z}
351
+ INDEX_PATTERN = /^index ([0-9a-f]{4,40})\.\.([0-9a-f]{4,40})( ......)?/
352
+ FILE_MODE_PATTERN = /^(new|deleted) file mode (......)/
353
+ OLD_MODE_PATTERN = /^old mode (......)/
354
+ NEW_MODE_PATTERN = /^new mode (......)/
355
+ BINARY_PATTERN = /^Binary files /
356
+ GIT_BINARY_PATCH_PATTERN = /^GIT binary patch$/
357
+ RENAME_FROM_PATTERN = /^rename from (.+)$/
358
+ RENAME_TO_PATTERN = /^rename to (.+)$/
359
+ COPY_FROM_PATTERN = /^copy from (.+)$/
360
+ COPY_TO_PATTERN = /^copy to (.+)$/
361
+ SIMILARITY_PATTERN = /^similarity index (\d+)%$/
362
+
363
+ PATCH_STATUS_MAP = {
364
+ 'new' => :added,
365
+ 'deleted' => :deleted,
366
+ 'modified' => :modified,
367
+ 'renamed' => :renamed,
368
+ 'copied' => :copied,
369
+ 'type_changed' => :type_changed
370
+ }.freeze
371
+
372
+ module_function
373
+
374
+ # Parse combined patch + numstat + shortstat output into DiffResult
375
+ #
376
+ # @param output [String] combined output
377
+ # @param include_dirstat [Boolean] whether dirstat output is expected
378
+ # @return [Git::DiffResult]
379
+ #
380
+ def parse(output, include_dirstat: false)
381
+ return Diff.empty_result if output.empty?
382
+
383
+ numstat_lines, shortstat_line, dirstat_lines, patch_text = split_sections(output, include_dirstat)
384
+ numstat_map = Numstat.parse_as_map(numstat_lines)
385
+
386
+ Diff.build_result(
387
+ files: PatchFileParser.new(patch_text, numstat_map).parse,
388
+ shortstat: Diff.parse_shortstat(shortstat_line),
389
+ dirstat: include_dirstat ? Diff.parse_dirstat(dirstat_lines) : nil
390
+ )
391
+ end
392
+
393
+ # Split output into numstat, shortstat, dirstat, and patch sections
394
+ #
395
+ # @param output [String] combined output
396
+ # @param include_dirstat [Boolean] whether to expect dirstat section
397
+ # @return [Array<Array<String>, String|nil, Array<String>, String>]
398
+ #
399
+ def split_sections(output, include_dirstat)
400
+ lines = output.lines
401
+ first_diff_index = lines.index { |l| l.start_with?('diff --git') } || lines.length
402
+ pre_diff_lines = lines[0...first_diff_index].map(&:chomp).reject(&:empty?)
403
+ patch_text = lines[first_diff_index..].join
404
+ split_pre_diff(pre_diff_lines, include_dirstat, patch_text)
405
+ end
406
+
407
+ def split_pre_diff(pre_diff_lines, include_dirstat, patch_text)
408
+ shortstat_index = pre_diff_lines.index { |l| l.match?(/^\s*\d+\s+files?\s+changed/) }
409
+ return [pre_diff_lines, nil, [], patch_text] unless shortstat_index
410
+
411
+ [pre_diff_lines[0...shortstat_index], pre_diff_lines[shortstat_index],
412
+ include_dirstat ? pre_diff_lines[(shortstat_index + 1)..] : [], patch_text]
413
+ end
414
+
415
+ # Methods for parsing patch metadata lines (index, mode, rename, etc.)
416
+ # @api private
417
+ module PatchMetadataParser
418
+ private
419
+
420
+ def parse_metadata_line(line)
421
+ try_parse_index(line)
422
+ try_parse_file_mode(line)
423
+ try_parse_old_new_mode(line)
424
+ try_parse_rename(line)
425
+ try_parse_similarity(line)
426
+ try_mark_binary(line)
427
+ end
428
+
429
+ def try_parse_index(line)
430
+ return unless (match = line.match(INDEX_PATTERN))
431
+
432
+ @current_file[:src_sha] = match[1]
433
+ @current_file[:dst_sha] = match[2]
434
+ return unless (mode = match[3]&.strip)
435
+ return unless @current_file[:src_mode].nil? && @current_file[:dst_mode].nil?
436
+
437
+ @current_file[:src_mode] = @current_file[:dst_mode] = mode
438
+ end
439
+
440
+ def try_parse_file_mode(line)
441
+ return unless (match = line.match(FILE_MODE_PATTERN))
442
+
443
+ type, mode = match.captures
444
+ @current_file[:status] = PATCH_STATUS_MAP.fetch(type, :modified)
445
+ apply_file_mode(type, mode)
446
+ end
447
+
448
+ def try_parse_old_new_mode(line)
449
+ if (match = line.match(OLD_MODE_PATTERN))
450
+ @current_file[:src_mode] = match[1]
451
+ detect_type_change
452
+ elsif (match = line.match(NEW_MODE_PATTERN))
453
+ @current_file[:dst_mode] = match[1]
454
+ detect_type_change
455
+ end
456
+ end
457
+
458
+ def detect_type_change
459
+ src_mode = @current_file[:src_mode]
460
+ dst_mode = @current_file[:dst_mode]
461
+ return unless src_mode && dst_mode
462
+
463
+ # Type change occurs when the file type bits differ (e.g., 100644 vs 120000)
464
+ # The first 3 digits represent the file type
465
+ @current_file[:status] = :type_changed if src_mode[0, 3] != dst_mode[0, 3]
466
+ end
467
+
468
+ def apply_file_mode(type, mode)
469
+ case type
470
+ when 'new'
471
+ @current_file[:dst_mode] = mode
472
+ @current_file[:src_path] = nil
473
+ when 'deleted'
474
+ @current_file[:src_mode] = mode
475
+ @current_file[:dst_path] = nil
476
+ end
477
+ end
478
+
479
+ def try_parse_rename(line)
480
+ try_parse_rename_or_copy(line, RENAME_FROM_PATTERN, RENAME_TO_PATTERN, :renamed) ||
481
+ try_parse_rename_or_copy(line, COPY_FROM_PATTERN, COPY_TO_PATTERN, :copied)
482
+ end
483
+
484
+ def try_parse_rename_or_copy(line, from_pattern, to_pattern, status)
485
+ if (match = line.match(from_pattern))
486
+ @current_file[:src_path] = Diff.unescape_path(match[1])
487
+ @current_file[:status] = status
488
+ elsif (match = line.match(to_pattern))
489
+ @current_file[:dst_path] = Diff.unescape_path(match[1])
490
+ @current_file[:status] = status
491
+ end
492
+ end
493
+
494
+ def try_parse_similarity(line)
495
+ return unless (match = line.match(SIMILARITY_PATTERN))
496
+
497
+ @current_file[:similarity] = match[1].to_i
498
+ end
499
+
500
+ def try_mark_binary(line)
501
+ @current_file[:binary] = true if line.match?(BINARY_PATTERN) || line.match?(GIT_BINARY_PATCH_PATTERN)
502
+ end
503
+ end
504
+
505
+ # Stateful parser for unified diff patch output
506
+ # @api private
507
+ class PatchFileParser
508
+ include PatchMetadataParser
509
+
510
+ def initialize(patch_text, numstat_map = {})
511
+ @patch_text = patch_text
512
+ @numstat_map = numstat_map
513
+ @files = []
514
+ @current_file = nil
515
+ end
516
+
517
+ def parse
518
+ @patch_text.split("\n").each { |line| process_line(line) }
519
+ finalize_current_file
520
+ @files
521
+ end
522
+
523
+ private
524
+
525
+ def process_line(line)
526
+ if (match = line.match(DIFF_HEADER_PATTERN))
527
+ start_new_file(match, line)
528
+ elsif @current_file
529
+ append_to_current_file(line)
530
+ end
531
+ end
532
+
533
+ def start_new_file(match, line)
534
+ finalize_current_file
535
+ @current_file = default_file_state.merge(
536
+ patch: line,
537
+ src_path: Git::EscapedPath.new(match[2]).unescape,
538
+ dst_path: Git::EscapedPath.new(match[4]).unescape
539
+ )
540
+ end
541
+
542
+ def default_file_state
543
+ { src_mode: nil, dst_mode: nil, src_sha: '', dst_sha: '',
544
+ src_path: nil, dst_path: nil, status: :modified, similarity: nil, binary: false }
545
+ end
546
+
547
+ def append_to_current_file(line)
548
+ parse_metadata_line(line)
549
+ @current_file[:patch] = "#{@current_file[:patch]}\n#{line}"
550
+ end
551
+
552
+ def finalize_current_file
553
+ return unless @current_file
554
+
555
+ @files << build_patch_info
556
+ @current_file = nil
557
+ end
558
+
559
+ def build_patch_info
560
+ path = @current_file[:dst_path] || @current_file[:src_path]
561
+ stats = @numstat_map.fetch(path, { insertions: 0, deletions: 0 })
562
+
563
+ Git::DiffFilePatchInfo.new(
564
+ src: build_file_ref(:src), dst: build_file_ref(:dst),
565
+ patch: @current_file[:patch], status: @current_file[:status],
566
+ similarity: @current_file[:similarity], binary: @current_file[:binary],
567
+ insertions: stats[:insertions], deletions: stats[:deletions]
568
+ )
569
+ end
570
+
571
+ def build_file_ref(side)
572
+ path = @current_file[:"#{side}_path"]
573
+ return nil if path.nil?
574
+
575
+ Git::FileRef.new(
576
+ mode: @current_file[:"#{side}_mode"] || '',
577
+ sha: @current_file[:"#{side}_sha"] || '',
578
+ path: path
579
+ )
580
+ end
581
+ end
582
+ end
583
+ end
584
+ end
585
+ end