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