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,785 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'git/commands/diff'
5
+ require 'git/commands/diff_files'
6
+ require 'git/commands/diff_index'
7
+ require 'git/commands/status'
8
+ require 'git/diff'
9
+ require 'git/diff_path_status'
10
+ require 'git/diff_stats'
11
+ require 'git/escaped_path'
12
+ require 'git/repository/shared_private'
13
+
14
+ module Git
15
+ class Repository
16
+ # Facade methods for comparing commits and trees using `git diff`
17
+ #
18
+ # Included by {Git::Repository}.
19
+ #
20
+ # @api private
21
+ #
22
+ module Diffing
23
+ # Option keys accepted by {#diff_full}
24
+ #
25
+ # @return [Array<Symbol>]
26
+ #
27
+ # @api private
28
+ #
29
+ DIFF_FULL_ALLOWED_OPTS = %i[path_limiter].freeze
30
+ private_constant :DIFF_FULL_ALLOWED_OPTS
31
+
32
+ # Returns the full unified diff patch text between two trees
33
+ #
34
+ # Compares (1) two commits, (2) a commit against the working tree, or (3) the
35
+ # index against the working tree using `git diff -p`, and returns the raw
36
+ # unified diff patch output.
37
+ #
38
+ # **Comparing two commits**
39
+ #
40
+ # When both obj1 and obj2 are provided, the comparison is between those two
41
+ # refs (commits, tags, branches, etc.).
42
+ #
43
+ # **Comparing a commit against the working tree**
44
+ #
45
+ # When only obj1 is provided (and isn't nil), the comparison is between obj1 and
46
+ # the working tree; the patch reflects all changes since obj1.
47
+ #
48
+ # **Comparing the index against the working tree**
49
+ #
50
+ # When obj1 is explicitly `nil` then obj2 must be omitted or `nil`. In this case,
51
+ # the comparison is between the index and the working tree; the patch reflects
52
+ # unstaged changes.
53
+ #
54
+ # @example Get the working tree patch since HEAD
55
+ # repo.diff_full #=> "diff --git a/lib/foo.rb b/lib/foo.rb\n..."
56
+ #
57
+ # @example Compare two specific commits
58
+ # repo.diff_full('abc1234', 'def5678')
59
+ #
60
+ # @example Get unstaged changes (index vs. working tree)
61
+ # repo.diff_full(nil)
62
+ #
63
+ # @example Limit the diff to a sub-path
64
+ # repo.diff_full('HEAD~1', 'HEAD', path_limiter: 'lib/')
65
+ #
66
+ # @param obj1 [String, nil] the first commit or object to compare; defaults to
67
+ # `'HEAD'`
68
+ #
69
+ # @param obj2 [String, nil] the second commit or object to compare
70
+ #
71
+ # @param opts [Hash] options to filter the diff
72
+ #
73
+ # @option opts [String, Pathname, Array<String, Pathname>, nil] :path_limiter (nil)
74
+ # limit the diff to the given path(s)
75
+ #
76
+ # @return [String] the unified diff patch output
77
+ #
78
+ # @raise [ArgumentError] if unsupported options are provided
79
+ #
80
+ # @raise [ArgumentError] if `obj1` is `nil` but `obj2` is not OR if `obj1` or `obj2` starts with `"-"`
81
+ #
82
+ # @raise [Git::FailedError] if git exits outside the allowed range (exit code > 2)
83
+ #
84
+ # @see https://git-scm.com/docs/git-diff git-diff documentation
85
+ #
86
+ def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
87
+ SharedPrivate.assert_valid_opts!(DIFF_FULL_ALLOWED_OPTS, **opts)
88
+ raise ArgumentError, 'Invalid arguments: obj1 is nil but obj2 is not' if obj1.nil? && !obj2.nil?
89
+
90
+ pathspecs = Private.normalize_pathspecs(opts[:path_limiter], 'path limiter')
91
+ result = Git::Commands::Diff.new(@execution_context).call(
92
+ *[obj1, obj2].compact,
93
+ patch: true, numstat: true, shortstat: true,
94
+ src_prefix: 'a/', dst_prefix: 'b/',
95
+ path: pathspecs
96
+ )
97
+ Private.extract_patch_text(result.stdout)
98
+ end
99
+
100
+ # Option keys accepted by {#diff_numstat}
101
+ #
102
+ # @return [Array<Symbol>]
103
+ #
104
+ # @api private
105
+ #
106
+ DIFF_NUMSTAT_ALLOWED_OPTS = %i[path_limiter].freeze
107
+ private_constant :DIFF_NUMSTAT_ALLOWED_OPTS
108
+
109
+ # Returns per-file insertion/deletion counts and totals between two trees
110
+ #
111
+ # Compares (1) two commits, (2) a commit against the working tree, or (3) the
112
+ # index against the working tree using `git diff --numstat`, and returns a
113
+ # structured hash of per-file insertion and deletion line counts together with
114
+ # aggregate totals.
115
+ #
116
+ # **Comparing two commits**
117
+ #
118
+ # When both obj1 and obj2 are provided, the comparison is between those two
119
+ # refs (commits, tags, branches, etc.).
120
+ #
121
+ # **Comparing a commit against the working tree**
122
+ #
123
+ # When only obj1 is provided (and isn't nil), the comparison is between obj1 and
124
+ # the working tree; the stats reflect all changes since obj1.
125
+ #
126
+ # **Comparing the index against the working tree**
127
+ #
128
+ # When obj1 is explicitly `nil` then obj2 must be omitted or `nil`. In this case,
129
+ # the comparison is between the index and the working tree; the stats reflect
130
+ # unstaged changes.
131
+ #
132
+ # @example Compare two specific commits
133
+ # repo.diff_numstat('abc1234', 'def5678')
134
+ #
135
+ # @example Get working tree changes since HEAD
136
+ # repo.diff_numstat
137
+ # #=> {
138
+ # # total: { insertions: 5, deletions: 2, lines: 7, files: 1 },
139
+ # # files: { "lib/foo.rb" => { insertions: 5, deletions: 2 } }
140
+ # # }
141
+ #
142
+ # @example Get unstaged changes (index vs. working tree)
143
+ # repo.diff_numstat(nil) #=> { ... }
144
+ #
145
+ # @example Limit the stats to a sub-path
146
+ # repo.diff_numstat('HEAD~1', 'HEAD', path_limiter: 'lib/')
147
+ #
148
+ # @param obj1 [String, nil] the first commit or object to compare; defaults to
149
+ # `'HEAD'`
150
+ #
151
+ # @param obj2 [String, nil] the second commit or object to compare
152
+ #
153
+ # @param opts [Hash] options to filter the diff
154
+ #
155
+ # @option opts [String, Pathname, Array<String, Pathname>, nil] :path_limiter (nil)
156
+ # limit the stats to the given path(s)
157
+ #
158
+ # @return [Hash] per-file insertion and deletion counts plus aggregate totals
159
+ #
160
+ # ```
161
+ # {
162
+ # total: { insertions: Integer, deletions: Integer, lines: Integer, files: Integer },
163
+ # files: { "path/to/file" => { insertions: Integer, deletions: Integer } }
164
+ # }
165
+ # ```
166
+ #
167
+ # @raise [ArgumentError] if unsupported options are provided
168
+ #
169
+ # @raise [ArgumentError] if `obj1` is `nil` but `obj2` is not OR if `obj1` or `obj2` starts with `"-"`
170
+ #
171
+ # @raise [Git::FailedError] if git exits outside the allowed range (exit code > 2)
172
+ #
173
+ # @see https://git-scm.com/docs/git-diff git-diff documentation
174
+ #
175
+ def diff_numstat(obj1 = 'HEAD', obj2 = nil, opts = {})
176
+ SharedPrivate.assert_valid_opts!(DIFF_NUMSTAT_ALLOWED_OPTS, **opts)
177
+ raise ArgumentError, 'Invalid arguments: obj1 is nil but obj2 is not' if obj1.nil? && !obj2.nil?
178
+
179
+ pathspecs = Private.normalize_pathspecs(opts[:path_limiter], 'path limiter')
180
+ result = Git::Commands::Diff.new(@execution_context).call(
181
+ *[obj1, obj2].compact,
182
+ numstat: true, shortstat: true, src_prefix: 'a/', dst_prefix: 'b/',
183
+ path: pathspecs
184
+ )
185
+ Private.parse_numstat_output(result.stdout)
186
+ end
187
+
188
+ # Option keys accepted by {#diff_stats}
189
+ #
190
+ # @return [Array<Symbol>]
191
+ #
192
+ # @api private
193
+ #
194
+ DIFF_STATS_ALLOWED_OPTS = %i[path_limiter].freeze
195
+ private_constant :DIFF_STATS_ALLOWED_OPTS
196
+
197
+ # Returns the stats between two trees as a {Git::DiffStats} object
198
+ #
199
+ # Compares (1) two commits, (2) a commit against the working tree, or (3) the
200
+ # index against the working tree and constructs a lazy {Git::DiffStats} that
201
+ # computes per-file insertion and deletion counts on demand when its accessor
202
+ # methods are called.
203
+ #
204
+ # **Comparing two commits**
205
+ #
206
+ # When both obj1 and obj2 are provided, the comparison is between those two
207
+ # refs (commits, tags, branches, etc.).
208
+ #
209
+ # **Comparing a commit against the working tree**
210
+ #
211
+ # When only obj1 is provided (and isn't nil), the comparison is between obj1 and
212
+ # the working tree; the stats reflect all changes since obj1.
213
+ #
214
+ # **Comparing the index against the working tree**
215
+ #
216
+ # When obj1 is explicitly `nil` then obj2 must be omitted or `nil`. In this case,
217
+ # the comparison is between the index and the working tree; the stats reflect
218
+ # unstaged changes.
219
+ #
220
+ # @example Get working tree stats since HEAD
221
+ # stats = repo.diff_stats
222
+ # stats.insertions #=> 3
223
+ # stats.deletions #=> 1
224
+ #
225
+ # @example Compare two specific commits
226
+ # repo.diff_stats('abc1234', 'def5678')
227
+ #
228
+ # @example Get unstaged stats (index vs. working tree)
229
+ # repo.diff_stats(nil).insertions
230
+ #
231
+ # @example Limit stats to a sub-path
232
+ # repo.diff_stats('HEAD~1', 'HEAD', path_limiter: 'lib/')
233
+ #
234
+ # @param obj1 [String, nil] the first commit or object to compare; defaults to
235
+ # `'HEAD'`
236
+ #
237
+ # @param obj2 [String, nil] the second commit or object to compare
238
+ #
239
+ # @param opts [Hash] options to filter the diff
240
+ #
241
+ # @option opts [String, Pathname, Array<String, Pathname>, nil] :path_limiter (nil)
242
+ # limit the stats to the given path(s)
243
+ #
244
+ # @return [Git::DiffStats] a lazy stats object for the comparison
245
+ #
246
+ # @raise [ArgumentError] if unsupported options are provided
247
+ #
248
+ # @raise [ArgumentError] if `obj1` is `nil` but `obj2` is not OR if `obj1` or `obj2` starts with `"-"`
249
+ #
250
+ # @see #diff_numstat
251
+ #
252
+ # @see https://git-scm.com/docs/git-diff git-diff documentation
253
+ #
254
+ def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
255
+ SharedPrivate.assert_valid_opts!(DIFF_STATS_ALLOWED_OPTS, **opts)
256
+ raise ArgumentError, 'Invalid arguments: obj1 is nil but obj2 is not' if obj1.nil? && !obj2.nil?
257
+
258
+ Git::DiffStats.new(self, obj1, obj2, opts[:path_limiter])
259
+ end
260
+
261
+ # Returns a lazy {Git::Diff} object for the comparison between two trees
262
+ #
263
+ # Compares (1) two commits, (2) a commit against the working tree, or (3) the
264
+ # index against the working tree. The returned {Git::Diff} is lazy — it does
265
+ # not run any git commands until an accessor method (e.g., {Git::Diff#patch},
266
+ # {Git::Diff#each}) is called.
267
+ #
268
+ # Use {Git::Diff#path} to limit the diff to a sub-path after construction.
269
+ #
270
+ # @example Get the diff since HEAD
271
+ # diff = repo.diff
272
+ # diff.patch #=> "diff --git a/lib/foo.rb ..."
273
+ #
274
+ # @example Compare two specific commits
275
+ # repo.diff('abc1234', 'def5678').patch
276
+ #
277
+ # @example Limit to a sub-path
278
+ # repo.diff('HEAD~1', 'HEAD').path('lib/').patch
279
+ #
280
+ # @example Get unstaged changes (index vs. working tree)
281
+ # repo.diff(nil).patch
282
+ #
283
+ # @param obj1 [String, nil] the first commit or object to compare; defaults to
284
+ # `'HEAD'`
285
+ #
286
+ # @param obj2 [String, nil] the second commit or object to compare
287
+ #
288
+ # @return [Git::Diff] a lazy diff object for the comparison
289
+ #
290
+ # @see https://git-scm.com/docs/git-diff git-diff documentation
291
+ #
292
+ def diff(obj1 = 'HEAD', obj2 = nil)
293
+ Git::Diff.new(self, obj1, obj2)
294
+ end
295
+
296
+ # Option keys accepted by {#diff_path_status}
297
+ #
298
+ # @return [Array<Symbol>]
299
+ #
300
+ # @api private
301
+ #
302
+ DIFF_PATH_STATUS_ALLOWED_OPTS = %i[path_limiter path].freeze
303
+ private_constant :DIFF_PATH_STATUS_ALLOWED_OPTS
304
+
305
+ # Returns the file path status between two trees
306
+ #
307
+ # Compares (1) two commits, (2) a commit against the working tree, or (3) the
308
+ # index against the working tree and returns a {Git::DiffPathStatus} enumerating
309
+ # each changed file together with its status code (e.g. `"M"` for modified,
310
+ # `"A"` for added, `"D"` for deleted, `"R100"` for a rename with 100%
311
+ # similarity, etc.).
312
+ #
313
+ # **Comparing two commits**
314
+ #
315
+ # When both from and to are provided, the comparison is between those two
316
+ # refs (commits, tags, branches, etc.).
317
+ #
318
+ # **Comparing a commit against the working tree**
319
+ #
320
+ # When only from is provided (and isn't nil), the comparison is between from and
321
+ # the working tree; the status reflects all changes since from.
322
+ #
323
+ # **Comparing the index against the working tree**
324
+ #
325
+ # When from is explicitly `nil` then to must be omitted or `nil`. In this case,
326
+ # the comparison is between the index and the working tree; the status reflects
327
+ # unstaged changes.
328
+ #
329
+ # @example Get working tree path changes since HEAD
330
+ # repo.diff_path_status #=> #<Git::DiffPathStatus ...>
331
+ # repo.diff_path_status.to_h #=> { "README.md" => "M", "lib/foo.rb" => "A" }
332
+ #
333
+ # @example Compare two specific commits
334
+ # repo.diff_path_status('abc1234', 'def5678').to_h
335
+ #
336
+ # @example Get unstaged path changes (index vs. working tree)
337
+ # repo.diff_path_status(nil).to_h
338
+ #
339
+ # @example Limit the comparison to a sub-path
340
+ # repo.diff_path_status('HEAD~1', 'HEAD', path_limiter: 'lib/')
341
+ #
342
+ # @param from [String, nil] the first commit or object to compare; defaults to
343
+ # `'HEAD'`
344
+ #
345
+ # @param to [String, nil] the second commit or object to compare
346
+ #
347
+ # @param opts [Hash] options to filter the diff
348
+ #
349
+ # @option opts [String, Pathname, Array<String, Pathname>, nil] :path_limiter (nil)
350
+ # limit the status report to the given path(s)
351
+ #
352
+ # @option opts [String, Pathname, Array<String, Pathname>, nil] :path (nil)
353
+ # **deprecated** — use `:path_limiter` instead
354
+ #
355
+ # @return [Git::DiffPathStatus] the name-status report for the comparison
356
+ #
357
+ # @raise [ArgumentError] if unsupported options are provided
358
+ #
359
+ # @raise [ArgumentError] if `from` is `nil` but `to` is not OR if `from` or `to` starts with `"-"`
360
+ #
361
+ # @raise [Git::FailedError] if git exits outside the allowed range (exit code > 2)
362
+ #
363
+ # @see https://git-scm.com/docs/git-diff git-diff documentation
364
+ #
365
+ def diff_path_status(from = 'HEAD', to = nil, opts = {})
366
+ SharedPrivate.assert_valid_opts!(DIFF_PATH_STATUS_ALLOWED_OPTS, **opts)
367
+ raise ArgumentError, 'Invalid arguments: `from` is nil but `to` is not' if from.nil? && !to.nil?
368
+
369
+ path_limiter = Private.resolve_path_limiter(opts)
370
+ pathspecs = Private.normalize_pathspecs(path_limiter, 'path limiter')
371
+
372
+ result = Private.call_diff_command(@execution_context, from, to, pathspecs)
373
+ Git::DiffPathStatus.new(Private.extract_name_status_from_raw(result.stdout))
374
+ end
375
+
376
+ # Alias for {#diff_path_status}; provided for backward compatibility
377
+ #
378
+ # @return [Git::DiffPathStatus] the name-status report for the comparison
379
+ #
380
+ # @deprecated Use {#diff_path_status} instead
381
+ #
382
+ # @see #diff_path_status
383
+ alias diff_name_status diff_path_status
384
+
385
+ # Compares the index and the working directory
386
+ #
387
+ # Runs `git diff-files` to list files that differ between the index
388
+ # (staging area) and the working directory. These are changes that have
389
+ # been made to tracked files but not yet staged.
390
+ #
391
+ # @example List all files with unstaged changes
392
+ # repo.diff_files
393
+ # #=> {
394
+ # # "lib/foo.rb" => {
395
+ # # mode_index: "100644", mode_repo: "100644",
396
+ # # path: "lib/foo.rb", sha_repo: "abc1234",
397
+ # # sha_index: "0000000000000000000000000000000000000000",
398
+ # # type: "M"
399
+ # # }
400
+ # # }
401
+ #
402
+ # @return [Hash{String => Hash}] a hash keyed by file path
403
+ #
404
+ # Each value is a hash with the following keys (note the legacy naming
405
+ # where `:*_repo` holds index data and `:*_index` holds working tree data):
406
+ #
407
+ # * `:mode_index` [String] the working tree file mode (legacy name)
408
+ # * `:mode_repo` [String] the index (staging area) file mode (legacy name)
409
+ # * `:path` [String] the file path
410
+ # * `:sha_repo` [String] the SHA of the object in the index (staging area) (legacy name)
411
+ # * `:sha_index` [String] the SHA of the object in the working tree; all
412
+ # zeros when git has not computed the working tree blob SHA (legacy name)
413
+ # * `:type` [String] the status code (e.g. `"M"`, `"A"`, `"D"`)
414
+ #
415
+ # @raise [Git::FailedError] if git exits outside the allowed range (exit code > 1)
416
+ #
417
+ # @note The field names in the returned hash are **legacy names** inherited
418
+ # from `Git::Lib#diff_files` and appear counterintuitive: `:mode_repo`
419
+ # and `:sha_repo` hold **index (staging area)** values, while
420
+ # `:mode_index` and `:sha_index` hold **working tree** values.
421
+ #
422
+ # @see https://git-scm.com/docs/git-diff-files git-diff-files documentation
423
+ #
424
+ def diff_files
425
+ Git::Commands::Status.new(@execution_context).call
426
+ Private.parse_diff_files_output(
427
+ Git::Commands::DiffFiles.new(@execution_context).call.stdout
428
+ )
429
+ end
430
+
431
+ # Compares the working tree against the given tree object
432
+ #
433
+ # Runs `git diff-index <treeish>` (without `--cached`) to list files that
434
+ # differ between the given tree object (e.g. a commit or `"HEAD"`) and the
435
+ # working tree. The index is refreshed via `git status` first so that cached
436
+ # stat information is up to date.
437
+ #
438
+ # This is equivalent to the 4.x `Git::Lib#diff_index` behavior, which also
439
+ # ran `git diff-index` without `--cached`.
440
+ #
441
+ # @example List all working-tree files that differ from HEAD
442
+ # repo.diff_index('HEAD')
443
+ # #=> {
444
+ # # "lib/foo.rb" => {
445
+ # # mode_index: "100644", mode_repo: "100644",
446
+ # # path: "lib/foo.rb", sha_repo: "abc1234",
447
+ # # sha_index: "0000000000000000000000000000000000000000",
448
+ # # type: "M"
449
+ # # }
450
+ # # }
451
+ #
452
+ # @param treeish [String] the tree object to compare against (e.g. `'HEAD'`,
453
+ # a commit SHA, or a tag name)
454
+ #
455
+ # @return [Hash{String => Hash}] a hash keyed by file path
456
+ #
457
+ # Each value is a hash with the following keys (note the legacy naming
458
+ # where `:*_repo` holds tree data and `:*_index` holds working tree data):
459
+ #
460
+ # * `:mode_index` [String] the working tree file mode (legacy name)
461
+ # * `:mode_repo` [String] the tree (treeish) file mode (legacy name)
462
+ # * `:path` [String] the file path
463
+ # * `:sha_repo` [String] the SHA of the object in the tree (treeish) (legacy name)
464
+ # * `:sha_index` [String] the SHA of the object in the working tree; all
465
+ # zeros when git has not yet computed the working tree blob SHA (legacy name)
466
+ # * `:type` [String] the status code (e.g. `"M"`, `"A"`, `"D"`)
467
+ #
468
+ # @raise [Git::FailedError] if git exits outside the allowed range (exit code > 1)
469
+ #
470
+ # @note `git diff-index` without `--cached` uses the index as a stat cache:
471
+ # any file whose index entry differs from the tree is reported as changed,
472
+ # even when the on-disk working-tree content is byte-for-byte identical to
473
+ # the tree. A staged change that has been reverted in the working tree will
474
+ # therefore still appear in the result (because the index still differs from
475
+ # the tree).
476
+ #
477
+ # @note The field names in the returned hash are **legacy names** inherited
478
+ # from `Git::Lib#diff_index` and appear counterintuitive: `:mode_repo`
479
+ # and `:sha_repo` hold **tree (treeish)** values, while `:mode_index` and
480
+ # `:sha_index` hold **working tree** values.
481
+ #
482
+ # @see https://git-scm.com/docs/git-diff-index git-diff-index documentation
483
+ #
484
+ def diff_index(treeish)
485
+ Git::Commands::Status.new(@execution_context).call
486
+ Private.parse_diff_files_output(
487
+ Git::Commands::DiffIndex.new(@execution_context).call(treeish).stdout
488
+ )
489
+ end
490
+
491
+ # Private helpers local to {Git::Repository::Diffing}
492
+ #
493
+ # @api private
494
+ #
495
+ module Private
496
+ module_function
497
+
498
+ # Resolves the effective path limiter from the options hash
499
+ #
500
+ # When `:path_limiter` is present it is used directly and no warning is
501
+ # emitted. When only `:path` is present a deprecation warning is emitted
502
+ # and its value is used. Returns `nil` when neither key is present.
503
+ #
504
+ # @param opts [Hash] the options hash from {#diff_path_status}
505
+ #
506
+ # @option opts [String, Pathname, Array<String, Pathname>, nil] :path_limiter (nil)
507
+ # primary path limiter value used as-is when present
508
+ #
509
+ # @option opts [String, Pathname, Array<String, Pathname>, nil] :path (nil)
510
+ # **deprecated** — fallback path limiter when `:path_limiter` is not provided
511
+ #
512
+ # @return [String, Pathname, Array<String, Pathname>, nil]
513
+ # the effective path limiter
514
+ #
515
+ def resolve_path_limiter(opts)
516
+ if opts.key?(:path_limiter)
517
+ opts[:path_limiter]
518
+ elsif opts.key?(:path)
519
+ Git::Deprecation.warn(
520
+ 'Git::Repository#diff_path_status :path option is deprecated and will be removed in v6.0.0. ' \
521
+ 'Use :path_limiter instead.'
522
+ )
523
+ opts[:path]
524
+ end
525
+ end
526
+
527
+ # Extracts only the patch text from combined diff command output
528
+ #
529
+ # When {Git::Commands::Diff} is called with `patch: true, numstat: true,
530
+ # shortstat: true`, the stdout contains numstat lines, a shortstat summary
531
+ # line, and then the unified patch text starting at `"diff --git "`. This
532
+ # method strips the leading numstat/shortstat lines and returns only the
533
+ # patch portion.
534
+ #
535
+ # @param output [String] combined command output
536
+ #
537
+ # @return [String] only the patch text (may be empty when there are no
538
+ # changes)
539
+ #
540
+ def extract_patch_text(output)
541
+ match = output.match(/^diff --git /m)
542
+ match ? output[match.begin(0)..] : output
543
+ end
544
+
545
+ # Runs git-diff with `--raw` format options and returns the result
546
+ #
547
+ # @param execution_context [Git::ExecutionContext] the execution context
548
+ # used to run git commands
549
+ #
550
+ # @param from [String] first ref
551
+ #
552
+ # @param to [String, nil] second ref
553
+ #
554
+ # @param pathspecs [Array<String>, nil] path limiters
555
+ #
556
+ # @return [Git::CommandLine::Result] the result of calling `git diff`
557
+ #
558
+ def call_diff_command(execution_context, from, to, pathspecs)
559
+ Git::Commands::Diff.new(execution_context).call(
560
+ *[from, to].compact,
561
+ raw: true, numstat: true, shortstat: true,
562
+ src_prefix: 'a/', dst_prefix: 'b/',
563
+ path: pathspecs
564
+ )
565
+ end
566
+
567
+ # Normalizes path specifications for Git commands
568
+ #
569
+ # @param pathspecs [String, Pathname, Array<String, Pathname>, nil]
570
+ # the path(s) to normalize
571
+ #
572
+ # @param arg_name [String] the argument name used in error messages
573
+ #
574
+ # @return [Array<String>, nil] the normalized paths, or `nil` if none are valid
575
+ #
576
+ # @raise [ArgumentError] if any path is not a `String` or `Pathname`
577
+ #
578
+ def normalize_pathspecs(pathspecs, arg_name)
579
+ return nil unless pathspecs
580
+
581
+ normalized = Array(pathspecs)
582
+ validate_pathspec_types(normalized, arg_name)
583
+
584
+ normalized = normalized.map(&:to_s).reject(&:empty?)
585
+ return nil if normalized.empty?
586
+
587
+ normalized
588
+ end
589
+
590
+ # Raises an error if any element of `pathspecs` is not a `String` or `Pathname`
591
+ #
592
+ # @param pathspecs [Array] the path elements to validate
593
+ #
594
+ # @param arg_name [String] the argument name used in error messages
595
+ #
596
+ # @return [void]
597
+ #
598
+ # @raise [ArgumentError] if any element is not a `String` or `Pathname`
599
+ #
600
+ def validate_pathspec_types(pathspecs, arg_name)
601
+ return if pathspecs.all? { |p| p.is_a?(String) || p.is_a?(Pathname) }
602
+
603
+ raise ArgumentError, "Invalid #{arg_name}: must be a String, Pathname, or Array of Strings/Pathnames"
604
+ end
605
+
606
+ # Parses raw `git diff-files` output into a file-keyed hash
607
+ #
608
+ # Each output line has the format:
609
+ # `:old_mode new_mode old_sha new_sha status\tpath`
610
+ #
611
+ # The leading colon on `old_mode` is stripped when building
612
+ # the `:mode_repo` value.
613
+ #
614
+ # @param stdout [String] raw stdout from {Git::Commands::DiffFiles#call}
615
+ #
616
+ # @return [Hash{String => Hash}] a hash keyed by file path where each
617
+ # value has keys `:mode_index`, `:mode_repo`, `:path`, `:sha_repo`,
618
+ # `:sha_index`, and `:type`
619
+ #
620
+ def parse_diff_files_output(stdout)
621
+ stdout.split("\n").each_with_object({}) do |line, memo|
622
+ next if line.empty?
623
+
624
+ tab_pos = line.index("\t")
625
+ next unless tab_pos
626
+
627
+ path, entry = parse_diff_files_line(line, tab_pos)
628
+ memo[path] = entry
629
+ end
630
+ end
631
+
632
+ # Parses a single raw `git diff-files` output line into a path/entry pair
633
+ #
634
+ # @param line [String] a single non-empty line containing a tab character
635
+ #
636
+ # @param tab_pos [Integer] the index of the first tab in the line
637
+ #
638
+ # @return [Array(String, Hash)] two-element array of `[path, entry_hash]`
639
+ #
640
+ def parse_diff_files_line(line, tab_pos)
641
+ path = unescape_quoted_path(line[(tab_pos + 1)..])
642
+ parts = line[0, tab_pos].split
643
+ [path, build_diff_files_entry(path, parts)]
644
+ end
645
+
646
+ # Builds a single file-info hash for {#parse_diff_files_output}
647
+ #
648
+ # @param path [String] the file path
649
+ #
650
+ # @param parts [Array<String>] the whitespace-split fields from the info
651
+ # portion of the diff-files line: `[mode_src, mode_dest, sha_src,
652
+ # sha_dest, type]`
653
+ #
654
+ # @return [Hash] entry hash with keys `:mode_index`, `:mode_repo`, `:path`,
655
+ # `:sha_repo`, `:sha_index`, `:type`
656
+ #
657
+ def build_diff_files_entry(path, parts)
658
+ {
659
+ mode_index: parts[1],
660
+ mode_repo: parts[0].to_s[1, 7],
661
+ path: path,
662
+ sha_repo: parts[2],
663
+ sha_index: parts[3],
664
+ type: parts[4]
665
+ }
666
+ end
667
+
668
+ # Extracts name-status data from `--raw` diff output lines
669
+ #
670
+ # Raw lines have the format:
671
+ # :old_mode new_mode old_sha new_sha status\tpath
672
+ # or for renames/copies:
673
+ # :old_mode new_mode old_sha new_sha Rxx\told_path\tnew_path
674
+ #
675
+ # @param output [String] raw diff output
676
+ #
677
+ # @return [Hash{String => String}] mapping of file paths to status tokens
678
+ #
679
+ def extract_name_status_from_raw(output)
680
+ output.split("\n").each_with_object({}) do |line, memo|
681
+ next unless line.start_with?(':')
682
+
683
+ parts = line[1..].split(/\s+/, 5)
684
+ status_and_paths = parts[4].split("\t")
685
+ status = status_and_paths[0]
686
+ path = status_and_paths.length > 2 ? status_and_paths[2] : status_and_paths[1]
687
+ memo[unescape_quoted_path(path)] = status
688
+ end
689
+ end
690
+
691
+ # Parses combined `--numstat --shortstat` output into an insertions/deletions hash
692
+ #
693
+ # Strips the trailing shortstat summary line and empty lines, parses the
694
+ # remaining numstat lines, and returns a structured hash with per-file
695
+ # stats and aggregated totals.
696
+ #
697
+ # @param output [String] raw stdout from `git diff --numstat --shortstat`
698
+ #
699
+ # @return [Hash] per-file insertion and deletion counts plus aggregate totals
700
+ #
701
+ # ```
702
+ # {
703
+ # total: { insertions: Integer, deletions: Integer, lines: Integer, files: Integer },
704
+ # files: { "path/to/file" => { insertions: Integer, deletions: Integer } }
705
+ # }
706
+ # ```
707
+ #
708
+ def parse_numstat_output(output)
709
+ file_stats = extract_numstat_lines(output).map { |line| parse_numstat_line(line) }
710
+ { total: build_numstat_totals(file_stats), files: build_numstat_files(file_stats) }
711
+ end
712
+
713
+ # Builds the `:total` sub-hash for {#parse_numstat_output}
714
+ #
715
+ # @param file_stats [Array<Hash>] per-file stats from {#parse_numstat_line}
716
+ #
717
+ # @return [Hash] aggregate totals
718
+ #
719
+ # `{ insertions: Integer, deletions: Integer, lines: Integer, files: Integer }`
720
+ #
721
+ def build_numstat_totals(file_stats)
722
+ insertions = file_stats.sum { |s| s[:insertions] }
723
+ deletions = file_stats.sum { |s| s[:deletions] }
724
+ { insertions: insertions, deletions: deletions,
725
+ lines: insertions + deletions, files: file_stats.size }
726
+ end
727
+
728
+ # Builds the `:files` sub-hash for {#parse_numstat_output}
729
+ #
730
+ # @param file_stats [Array<Hash>] per-file stats from {#parse_numstat_line}
731
+ #
732
+ # @return [Hash{String => Hash}] per-file insertion and deletion counts
733
+ #
734
+ def build_numstat_files(file_stats)
735
+ file_stats.to_h { |s| [s[:filename], s.slice(:insertions, :deletions)] }
736
+ end
737
+
738
+ # Filters raw numstat+shortstat output to only the numstat lines
739
+ #
740
+ # @param output [String] combined command output
741
+ #
742
+ # @return [Array<String>] only the numstat lines (no empties, no shortstat line)
743
+ #
744
+ def extract_numstat_lines(output)
745
+ output.split("\n").reject { |l| l.empty? || l.match?(/^\s*\d+\s+files?\s+changed/) }
746
+ end
747
+
748
+ # Parses a single `--numstat` line into a stats hash
749
+ #
750
+ # Numstat lines have the format `<insertions>\t<deletions>\t<path>`.
751
+ # Quoted paths (containing non-ASCII or special characters) are unescaped.
752
+ #
753
+ # @param line [String] a single numstat output line
754
+ #
755
+ # @return [Hash] `{ filename: String, insertions: Integer, deletions: Integer }`
756
+ #
757
+ def parse_numstat_line(line)
758
+ insertions_s, deletions_s, filename = line.split("\t", 3)
759
+ { filename: unescape_quoted_path(filename), insertions: insertions_s.to_i, deletions: deletions_s.to_i }
760
+ end
761
+
762
+ # Unescapes a git-quoted path (e.g. `"quoted_file_\\342\\230\\240"`)
763
+ #
764
+ # Git quotes paths that contain non-ASCII or special characters by
765
+ # wrapping them in double-quotes and octal-escaping each byte. This
766
+ # method strips the surrounding quotes and delegates unescaping to
767
+ # {Git::EscapedPath}.
768
+ #
769
+ # @param path [String] the path as it appears in git output
770
+ #
771
+ # @return [String] the unescaped path
772
+ #
773
+ def unescape_quoted_path(path)
774
+ if path.start_with?('"') && path.end_with?('"')
775
+ Git::EscapedPath.new(path[1..-2]).unescape
776
+ else
777
+ path
778
+ end
779
+ end
780
+ end
781
+
782
+ private_constant :Private
783
+ end
784
+ end
785
+ end