git 4.4.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 -67
  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 -73
  45. data/CONTRIBUTING.md +694 -53
  46. data/README.md +17 -17
  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 +485 -420
  52. data/lib/git/branch.rb +117 -59
  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 -1084
  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 +226 -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 +117 -56
  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
data/lib/git/lib.rb CHANGED
@@ -1,9 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'args_builder'
4
+ require_relative 'commands'
4
5
 
5
6
  require 'git/command_line'
6
7
  require 'git/errors'
8
+ require 'git/parsers/branch'
9
+ require 'git/parsers/fsck'
10
+ require 'git/parsers/grep'
11
+ require 'git/parsers/stash'
12
+ require 'git/parsers/tag'
13
+ require 'git/url'
7
14
  require 'logger'
8
15
  require 'pathname'
9
16
  require 'pp'
@@ -16,6 +23,10 @@ module Git
16
23
  # Internal git operations
17
24
  # @api private
18
25
  class Lib
26
+ # Thread-safe cache for git versions, keyed by binary path
27
+ @git_version_cache_mutex = Mutex.new
28
+ @git_version_cache = {}
29
+
19
30
  # The path to the Git working copy. The default is '"./.git"'.
20
31
  #
21
32
  # @return [Pathname] the path to the Git working copy.
@@ -74,52 +85,29 @@ module Git
74
85
  end
75
86
  end
76
87
 
77
- INIT_OPTION_MAP = [
78
- { keys: [:bare], flag: '--bare', type: :boolean },
79
- { keys: [:initial_branch], flag: '--initial-branch', type: :valued_equals }
80
- ].freeze
81
-
82
- # creates or reinitializes the repository
88
+ # Creates or reinitializes the repository in the current directory
89
+ #
90
+ # This is a low-level method that just runs `git init` with the given options.
91
+ # For full repository initialization including directory creation and path
92
+ # resolution, use Git.init instead.
93
+ #
94
+ # @param opts [Hash] command options
95
+ #
96
+ # @option opts [Boolean, nil] :bare (nil) create a bare repository
97
+ #
98
+ # @option opts [String, nil] :initial_branch (nil) use the specified name for the initial branch
99
+ #
100
+ # @option opts [String, nil] :separate_git_dir (nil) path to put the .git directory (`--separate-git-dir`)
83
101
  #
84
- # options:
85
- # :bare
86
- # :working_directory
87
- # :initial_branch
102
+ # @option opts [String, nil] :repository (nil) deprecated — use `:separate_git_dir` instead
103
+ #
104
+ # @return [String] the command output
88
105
  #
89
106
  def init(opts = {})
90
- args = build_args(opts, INIT_OPTION_MAP)
91
- command('init', *args)
92
- end
93
-
94
- CLONE_OPTION_MAP = [
95
- { keys: [:bare], flag: '--bare', type: :boolean },
96
- { keys: [:recursive], flag: '--recursive', type: :boolean },
97
- { keys: [:mirror], flag: '--mirror', type: :boolean },
98
- { keys: [:branch], flag: '--branch', type: :valued_space },
99
- { keys: [:filter], flag: '--filter', type: :valued_space },
100
- { keys: %i[remote origin], flag: '--origin', type: :valued_space },
101
- { keys: [:config], flag: '--config', type: :repeatable_valued_space },
102
- {
103
- keys: [:single_branch],
104
- type: :custom,
105
- validator: ->(value) { [nil, true, false].include?(value) },
106
- builder: lambda do |value|
107
- case value
108
- when true
109
- ['--single-branch']
110
- when false
111
- ['--no-single-branch']
112
- else
113
- []
114
- end
115
- end
116
- },
117
- {
118
- keys: [:depth],
119
- type: :custom,
120
- builder: ->(value) { ['--depth', value.to_i] if value }
121
- }
122
- ].freeze
107
+ opts = opts.dup
108
+ opts[:separate_git_dir] ||= opts.delete(:repository)
109
+ Git::Commands::Init.new(self).call(**opts).stdout
110
+ end
123
111
 
124
112
  # Clones a repository into a newly created directory
125
113
  #
@@ -132,28 +120,42 @@ module Git
132
120
  #
133
121
  # @param [Hash] opts the options for this command
134
122
  #
135
- # @option opts [Boolean] :bare (false) if true, clone as a bare repository
123
+ # @option opts [Boolean, nil] :bare (nil) if true, clone as a bare repository
136
124
  #
137
- # @option opts [String] :branch the branch to checkout
125
+ # @option opts [String, nil] :branch (nil) the branch to checkout
138
126
  #
139
- # @option opts [String, Array] :config one or more configuration options to set
127
+ # @option opts [String, Array<String>, nil] :config (nil) one or more configuration options to set
140
128
  #
141
- # @option opts [Integer] :depth the number of commits back to pull
129
+ # @option opts [Integer, nil] :depth (nil) the number of commits back to pull
142
130
  #
143
- # @option opts [String] :filter specify partial clone
131
+ # @option opts [String, nil] :filter (nil) specify partial clone
144
132
  #
145
- # @option opts [String] :mirror set up a mirror of the source repository
133
+ # @option opts [String, nil] :git_ssh (nil) SSH command or binary to use for git over SSH
146
134
  #
147
- # @option opts [String] :origin the name of the remote
135
+ # @option opts [Logger, nil] :log (nil) Logger instance to use for git operations
148
136
  #
149
- # @option opts [String] :path an optional prefix for the directory parameter
137
+ # @option opts [Boolean, nil] :mirror (nil) set up a mirror of the source repository
150
138
  #
151
- # @option opts [String] :remote the name of the remote
139
+ # @option opts [String, nil] :origin (nil) the name of the remote
152
140
  #
153
- # @option opts [Boolean] :recursive after the clone is created, initialize all
154
- # within, using their default settings
141
+ # @option opts [String, nil] :chdir (nil) run `git clone` from this directory
155
142
  #
156
- # @option opts [Numeric, nil] :timeout the number of seconds to wait for the
143
+ # When given, `directory` (or the repository basename when `directory` is nil)
144
+ # is resolved relative to `:chdir`, just as if you had `cd`'d into it before
145
+ # running `git clone`. The returned path is the join of `:chdir` and the
146
+ # cloned directory path.
147
+ #
148
+ # @option opts [String] :path deprecated: use `:chdir` instead.
149
+ #
150
+ # @option opts [String] :remote deprecated: use `:origin` instead.
151
+ #
152
+ # @option opts [Boolean, String, Array<String>, nil] :recurse_submodules (nil) initialize
153
+ # submodules after cloning; pass `true` for all submodules, or a pathspec string/array
154
+ # for a subset
155
+ #
156
+ # @option opts [Boolean, String, Array<String>, nil] :recursive (nil) deprecated: use `:recurse_submodules` instead
157
+ #
158
+ # @option opts [Numeric, nil] :timeout (nil) the number of seconds to wait for the
157
159
  # command to complete
158
160
  #
159
161
  # See {Git::Lib#command} for more information about :timeout
@@ -162,16 +164,16 @@ module Git
162
164
  #
163
165
  # @todo make this work with SSH password or auth_key
164
166
  #
165
- def clone(repository_url, directory, opts = {})
166
- @path = opts[:path] || '.'
167
- clone_dir = opts[:path] ? File.join(@path, directory) : directory
168
-
169
- args = build_args(opts, CLONE_OPTION_MAP)
170
- args.push('--', repository_url, clone_dir)
171
-
172
- command('clone', *args, timeout: opts[:timeout])
173
-
174
- return_base_opts_from_clone(clone_dir, opts)
167
+ def clone(repository_url, directory = nil, opts = {})
168
+ opts = opts.dup
169
+ deprecate_clone_options!(opts)
170
+ chdir = opts.delete(:chdir)
171
+ execution_opts = extract_clone_execution_context_opts(opts)
172
+ opts[:chdir] = chdir if chdir
173
+ command_line_result = Git::Commands::Clone.new(self).call(repository_url, directory, **opts)
174
+ result = build_clone_result(command_line_result, execution_opts)
175
+ prefix_clone_result_paths!(result, chdir)
176
+ result
175
177
  end
176
178
 
177
179
  # Returns the name of the default branch of the given repository
@@ -181,7 +183,7 @@ module Git
181
183
  # @return [String] the name of the default branch
182
184
  #
183
185
  def repository_default_branch(repository)
184
- output = command('ls-remote', '--symref', '--', repository, 'HEAD')
186
+ output = Git::Commands::LsRemote.new(self).call(repository, 'HEAD', symref: true).stdout
185
187
 
186
188
  match_data = output.match(%r{^ref: refs/remotes/origin/(?<default_branch>[^\t]+)\trefs/remotes/origin/HEAD$})
187
189
  return match_data[:default_branch] if match_data
@@ -194,29 +196,6 @@ module Git
194
196
 
195
197
  ## READ COMMANDS ##
196
198
 
197
- # The map defining how to translate user options to git command arguments.
198
- DESCRIBE_OPTION_MAP = [
199
- { keys: [:all], flag: '--all', type: :boolean },
200
- { keys: [:tags], flag: '--tags', type: :boolean },
201
- { keys: [:contains], flag: '--contains', type: :boolean },
202
- { keys: [:debug], flag: '--debug', type: :boolean },
203
- { keys: [:long], flag: '--long', type: :boolean },
204
- { keys: [:always], flag: '--always', type: :boolean },
205
- { keys: %i[exact_match exact-match], flag: '--exact-match', type: :boolean },
206
- { keys: [:abbrev], flag: '--abbrev', type: :valued_equals },
207
- { keys: [:candidates], flag: '--candidates', type: :valued_equals },
208
- { keys: [:match], flag: '--match', type: :valued_equals },
209
- {
210
- keys: [:dirty],
211
- type: :custom,
212
- builder: lambda do |value|
213
- return '--dirty' if value == true
214
-
215
- "--dirty=#{value}" if value.is_a?(String)
216
- end
217
- }
218
- ].freeze
219
-
220
199
  # Finds most recent tag that is reachable from a commit
221
200
  #
222
201
  # @see https://git-scm.com/docs/git-describe git-describe
@@ -225,13 +204,13 @@ module Git
225
204
  #
226
205
  # @param opts [Hash] the given options
227
206
  #
228
- # @option opts :all [Boolean]
229
- # @option opts :tags [Boolean]
230
- # @option opts :contains [Boolean]
231
- # @option opts :debug [Boolean]
232
- # @option opts :long [Boolean]
233
- # @option opts :always [Boolean]
234
- # @option opts :exact_match [Boolean]
207
+ # @option opts [Boolean, nil] :all (nil) use refs from all branches, tags, and remotes
208
+ # @option opts [Boolean, nil] :tags (nil) consider any tag, not just annotated ones
209
+ # @option opts [Boolean, nil] :contains (nil) find the tag that comes after the commit
210
+ # @option opts [Boolean, nil] :debug (nil) enable verbose searching strategy output
211
+ # @option opts [Boolean, nil] :long (nil) always output the long format
212
+ # @option opts [Boolean, nil] :always (nil) show uniquely abbreviated commit as a fallback
213
+ # @option opts [Boolean, nil] :exact_match (nil) only output exact tag matches
235
214
  # @option opts :dirty [true, String]
236
215
  # @option opts :abbrev [String]
237
216
  # @option opts :candidates [String]
@@ -244,59 +223,14 @@ module Git
244
223
  def describe(commit_ish = nil, opts = {})
245
224
  assert_args_are_not_options('commit-ish object', commit_ish)
246
225
 
247
- args = build_args(opts, DESCRIBE_OPTION_MAP)
248
- args << commit_ish if commit_ish
249
-
250
- command('describe', *args)
251
- end
252
-
253
- # Return the commits that are within the given revision range
254
- #
255
- # @see https://git-scm.com/docs/git-log git-log
256
- #
257
- # @param opts [Hash] the given options
258
- #
259
- # @option opts :count [Integer] the maximum number of commits to return (maps to max-count)
260
- # @option opts :all [Boolean]
261
- # @option opts :cherry [Boolean]
262
- # @option opts :since [String]
263
- # @option opts :until [String]
264
- # @option opts :grep [String]
265
- # @option opts :author [String]
266
- # @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range
267
- #
268
- # Only :between or :object options can be used, not both.
269
- #
270
- # @option opts :object [String] the revision range for the git log command
271
- #
272
- # Only :between or :object options can be used, not both.
273
- #
274
- # @option opts :path_limiter [String, Pathname, Array<String, Pathname>] only
275
- # include commits that impact files from the specified paths
276
- #
277
- # @return [Array<String>] the log output
278
- #
279
- # @raise [ArgumentError] if the resulting revision range is a string starting with a hyphen
280
- #
281
- def log_commits(opts = {})
282
- assert_args_are_not_options('between', opts[:between]&.first)
283
- assert_args_are_not_options('object', opts[:object])
284
-
285
- arr_opts = log_common_options(opts)
286
-
287
- arr_opts << '--pretty=oneline'
288
-
289
- arr_opts += log_path_options(opts)
226
+ # Translate legacy :"exact-match" (hyphenated) key to :exact_match (underscored)
227
+ opts = opts.dup
228
+ opts[:exact_match] ||= opts.delete(:'exact-match') if opts.key?(:'exact-match')
290
229
 
291
- log_or_empty_on_unborn { command_lines('log', *arr_opts).map { |l| l.split.first } }
230
+ commit_ishes = Array(commit_ish).compact
231
+ Git::Commands::Describe.new(self).call(*commit_ishes, **opts).stdout
292
232
  end
293
233
 
294
- FULL_LOG_EXTRA_OPTIONS_MAP = [
295
- { type: :static, flag: '--pretty=raw' },
296
- { keys: [:skip], flag: '--skip', type: :valued_equals },
297
- { keys: [:merges], flag: '--merges', type: :boolean }
298
- ].freeze
299
-
300
234
  # Return the commits that are within the given revision range
301
235
  #
302
236
  # @see https://git-scm.com/docs/git-log git-log
@@ -306,9 +240,9 @@ module Git
306
240
  # @option opts :count [Integer] the maximum number of commits to return (maps to
307
241
  # max-count)
308
242
  #
309
- # @option opts :all [Boolean]
243
+ # @option opts [Boolean, nil] :all (nil) include commits reachable from any ref
310
244
  #
311
- # @option opts :cherry [Boolean]
245
+ # @option opts [Boolean, nil] :cherry (nil) omit commits equivalent to cherry-picked commits
312
246
  #
313
247
  # @option opts :since [String]
314
248
  #
@@ -352,17 +286,11 @@ module Git
352
286
  # :object) is a string starting with a hyphen
353
287
  #
354
288
  def full_log_commits(opts = {})
355
- assert_args_are_not_options('between', opts[:between]&.first)
356
- assert_args_are_not_options('object', opts[:object])
289
+ assert_valid_opts(opts, FULL_LOG_ALLOWED_OPTS)
290
+ validate_log_count_option!(opts)
357
291
 
358
- args = log_common_options(opts)
359
- args += build_args(opts, FULL_LOG_EXTRA_OPTIONS_MAP)
360
- args += log_path_options(opts)
361
-
362
- log_or_empty_on_unborn do
363
- full_log = command_lines('log', *args)
364
- process_commit_log_data(full_log)
365
- end
292
+ call_opts = log_base_call_options(opts, skip: opts[:skip], merges: opts[:merges])
293
+ run_log_command(log_revision_range_args(opts), call_opts)
366
294
  end
367
295
 
368
296
  # Verify and resolve a Git revision to its full SHA
@@ -381,12 +309,9 @@ module Git
381
309
  # @return [String] the full commit hash
382
310
  #
383
311
  # @raise [Git::FailedError] if the revision cannot be resolved
384
- # @raise [ArgumentError] if the revision is a string starting with a hyphen
385
312
  #
386
313
  def rev_parse(revision)
387
- assert_args_are_not_options('rev', revision)
388
-
389
- command('rev-parse', '--revs-only', '--end-of-options', revision, '--')
314
+ Git::Commands::RevParse.new(self).call(revision, '--', revs_only: true).stdout
390
315
  end
391
316
 
392
317
  # For backwards compatibility with the old method name
@@ -403,42 +328,69 @@ module Git
403
328
  def name_rev(commit_ish)
404
329
  assert_args_are_not_options('commit_ish', commit_ish)
405
330
 
406
- command('name-rev', commit_ish).split[1]
331
+ Git::Commands::NameRev.new(self).call(commit_ish).stdout.split[1]
407
332
  end
408
333
 
409
334
  alias namerev name_rev
410
335
 
411
- # Output the contents or other properties of one or more objects.
336
+ # Returns the raw content of a git object, or streams it into a tempfile
337
+ #
338
+ # Without a block, the full content is buffered in memory and returned as a
339
+ # `String`. With a block, git output is streamed directly to disk without memory
340
+ # buffering — safe for large blobs.
412
341
  #
413
342
  # @see https://git-scm.com/docs/git-cat-file git-cat-file
414
343
  #
415
- # @example Get the contents of a file without a block
416
- # lib.cat_file_contents('README.md') # => "This is a README file\n"
344
+ # @overload cat_file_contents(object)
345
+ # Returns the object's raw content as a string.
417
346
  #
418
- # @example Get the contents of a file with a block
419
- # lib.cat_file_contents('README.md') { |f| f.read } # => "This is a README file\n"
347
+ # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
420
348
  #
421
- # @param object [String] the object whose contents to return
349
+ # @return [String] the raw content of the object
422
350
  #
423
- # @return [String] the object contents
351
+ # @raise [ArgumentError] if `object` starts with a hyphen
424
352
  #
425
- # @raise [ArgumentError] if object is a string starting with a hyphen
353
+ # @raise [Git::FailedError] if the object does not exist or the command fails
354
+ #
355
+ # @example Get the contents of a blob
356
+ # lib.cat_file_contents('HEAD:README.md') # => "This is a README file\n"
357
+ #
358
+ # @overload cat_file_contents(object, &block)
359
+ # Streams the object's raw content to a temporary file and yields it.
360
+ #
361
+ # Git output is written directly to a file on disk without being
362
+ # buffered in memory first, then the file is rewound and yielded to the block.
363
+ # The return value is whatever the block returns.
364
+ #
365
+ # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
366
+ #
367
+ # @yield [file] the temporary file containing the streamed content, positioned at the start
368
+ #
369
+ # @yieldparam file [File] readable `IO` object positioned at the beginning of the content
370
+ #
371
+ # @yieldreturn [Object] the value to return from this method
372
+ #
373
+ # @return [Object] the value returned by the block
374
+ #
375
+ # @raise [ArgumentError] if `object` starts with a hyphen
376
+ #
377
+ # @raise [Git::FailedError] if the object does not exist or the command fails
378
+ #
379
+ # @example Read a large blob without buffering it in memory
380
+ # lib.cat_file_contents('HEAD:large_file.bin') { |f| process(f) }
426
381
  #
427
382
  def cat_file_contents(object)
428
383
  assert_args_are_not_options('object', object)
429
384
 
430
- if block_given?
431
- Tempfile.create do |file|
432
- # If a block is given, write the output from the process to a temporary
433
- # file and then yield the file to the block
434
- #
435
- command('cat-file', '-p', object, out: file, err: file)
436
- file.rewind
437
- yield file
438
- end
439
- else
440
- # If a block is not given, return the file contents as a string
441
- command('cat-file', '-p', object)
385
+ return Git::Commands::CatFile::Raw.new(self).call(object, p: true).stdout unless block_given?
386
+
387
+ # Stream git output directly to a tempfile to avoid buffering large
388
+ # object content in memory when a block is given.
389
+ Tempfile.create do |file|
390
+ file.binmode
391
+ Git::Commands::CatFile::Raw.new(self).call(object, p: true, out: file)
392
+ file.rewind
393
+ yield file
442
394
  end
443
395
  end
444
396
 
@@ -457,7 +409,7 @@ module Git
457
409
  def cat_file_type(object)
458
410
  assert_args_are_not_options('object', object)
459
411
 
460
- command('cat-file', '-t', object)
412
+ cat_file_object_meta(object)[:type]
461
413
  end
462
414
 
463
415
  alias object_type cat_file_type
@@ -466,16 +418,16 @@ module Git
466
418
  #
467
419
  # @see https://git-scm.com/docs/git-cat-file git-cat-file
468
420
  #
469
- # @param object [String] the object to get the type
421
+ # @param object [String] the object to get the size of
470
422
  #
471
- # @return [String] the object type
423
+ # @return [Integer] the object size in bytes
472
424
  #
473
425
  # @raise [ArgumentError] if object is a string starting with a hyphen
474
426
  #
475
427
  def cat_file_size(object)
476
428
  assert_args_are_not_options('object', object)
477
429
 
478
- command('cat-file', '-s', object).to_i
430
+ cat_file_object_meta(object)[:size]
479
431
  end
480
432
 
481
433
  alias object_size cat_file_size
@@ -501,7 +453,7 @@ module Git
501
453
  def cat_file_commit(object)
502
454
  assert_args_are_not_options('object', object)
503
455
 
504
- cdata = command_lines('cat-file', 'commit', object)
456
+ cdata = Git::Commands::CatFile::Raw.new(self).call('commit', object).stdout.split("\n")
505
457
  process_commit_data(cdata, object)
506
458
  end
507
459
 
@@ -518,6 +470,26 @@ module Git
518
470
 
519
471
  CAT_FILE_HEADER_LINE = /\A(?<key>\w+) (?<value>.*)\z/
520
472
 
473
+ # Yields parsed header key/value pairs from `git cat-file` output lines
474
+ #
475
+ # Consumes header lines from the front of `data` until a non-header line is
476
+ # encountered. Continuation lines that begin with a space are folded into the
477
+ # previous header value using newline separators.
478
+ #
479
+ # @param data [Array<String>] mutable output lines from a cat-file response
480
+ #
481
+ # @yield [key, value] each parsed header pair
482
+ #
483
+ # @yieldparam key [String] header field name
484
+ #
485
+ # @yieldparam value [String] unfolded header value text
486
+ #
487
+ # @yieldreturn [void]
488
+ #
489
+ # @return [void]
490
+ #
491
+ # @raise [NoMethodError] if `data` contains non-string entries
492
+ #
521
493
  def each_cat_file_header(data)
522
494
  while (match = CAT_FILE_HEADER_LINE.match(data.shift))
523
495
  key = match[:key]
@@ -570,12 +542,39 @@ module Git
570
542
  def cat_file_tag(object)
571
543
  assert_args_are_not_options('object', object)
572
544
 
573
- tdata = command_lines('cat-file', 'tag', object)
545
+ tdata = Git::Commands::CatFile::Raw.new(self).call('tag', object).stdout.split("\n")
574
546
  process_tag_data(tdata, object)
575
547
  end
576
548
 
577
549
  alias tag_data cat_file_tag
578
550
 
551
+ def cat_file_object_meta(object)
552
+ stdout = Git::Commands::CatFile::Batch.new(self).call(object, batch_check: true).stdout
553
+ parse_cat_file_meta(stdout, object)
554
+ end
555
+
556
+ def parse_cat_file_meta(output, object)
557
+ line = output.to_s.lines.first.to_s.chomp
558
+
559
+ request_object_to_raise_error!(object) if line == "#{object} missing"
560
+
561
+ match = /\A\S+ (?<type>\S+) (?<size>\d+)\z/.match(line)
562
+ raise Git::UnexpectedResultError, "unexpected git cat-file metadata output: #{line.inspect}" if match.nil?
563
+
564
+ {
565
+ type: match[:type],
566
+ size: match[:size].to_i
567
+ }
568
+ end
569
+
570
+ # Re-request the missing object via non-batch cat-file so git produces a
571
+ # real non-zero exit and a FailedError with an accurate stderr message.
572
+ def request_object_to_raise_error!(object)
573
+ Git::Commands::CatFile::Raw.new(self).call(object, p: true)
574
+ raise Git::UnexpectedResultError,
575
+ "expected git cat-file to raise Git::FailedError for missing object #{object.inspect}"
576
+ end
577
+
579
578
  def process_tag_data(data, name)
580
579
  hsh = { 'name' => name }
581
580
 
@@ -650,32 +649,48 @@ module Git
650
649
  end
651
650
  private_constant :RawLogParser
652
651
 
653
- LS_TREE_OPTION_MAP = [
654
- { keys: [:recursive], flag: '-r', type: :boolean }
655
- ].freeze
652
+ # Allowed option keys for {#ls_tree}
653
+ LS_TREE_ALLOWED_OPTS = %i[recursive path].freeze
656
654
 
655
+ # Lists the objects in a git tree
656
+ #
657
+ # @param sha [String] the tree-ish object to list
658
+ #
659
+ # @param opts [Hash] additional options
660
+ #
661
+ # @return [Hash<String, Hash<String, Hash>>] parsed ls-tree output
662
+ #
663
+ # @api private
664
+ #
657
665
  def ls_tree(sha, opts = {})
658
- data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
659
- args = build_args(opts, LS_TREE_OPTION_MAP)
660
-
661
- args.unshift(sha)
662
- args << opts[:path] if opts[:path]
666
+ assert_valid_opts(opts, LS_TREE_ALLOWED_OPTS)
667
+ r_value = opts[:recursive]
668
+ paths = Array(opts[:path]).compact
669
+ safe_options = {}
670
+ safe_options[:r] = r_value unless r_value.nil?
671
+ result = Git::Commands::LsTree.new(self).call(sha, *paths, **safe_options)
672
+ parse_ls_tree_output(result.stdout)
673
+ end
663
674
 
664
- command_lines('ls-tree', *args).each do |line|
675
+ def parse_ls_tree_output(output)
676
+ data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
677
+ output.split("\n").each do |line|
665
678
  (info, filenm) = split_status_line(line)
666
- (mode, type, sha) = info.split
667
- data[type][filenm] = { mode: mode, sha: sha }
679
+ (mode, type, entry_sha) = info.split
680
+ data[type][filenm] = { mode: mode, sha: entry_sha }
668
681
  end
669
-
670
682
  data
671
683
  end
684
+ private :parse_ls_tree_output
672
685
 
673
- def mv(file1, file2)
674
- command_lines('mv', '--', file1, file2)
686
+ # @return [String] the command output
687
+ #
688
+ def mv(source, destination, options = {})
689
+ Git::Commands::Mv.new(self).call(*Array(source), destination, verbose: true, **options).stdout
675
690
  end
676
691
 
677
692
  def full_tree(sha)
678
- command_lines('ls-tree', '-r', sha)
693
+ Git::Commands::LsTree.new(self).call(sha, r: true).stdout.split("\n")
679
694
  end
680
695
 
681
696
  def tree_depth(sha)
@@ -683,38 +698,12 @@ module Git
683
698
  end
684
699
 
685
700
  def change_head_branch(branch_name)
686
- command('symbolic-ref', 'HEAD', "refs/heads/#{branch_name}")
701
+ Git::Commands::SymbolicRef::Update.new(self).call('HEAD', "refs/heads/#{branch_name}")
687
702
  end
688
703
 
689
- BRANCH_LINE_REGEXP = /
690
- ^
691
- # Prefix indicates if this branch is checked out. The prefix is one of:
692
- (?:
693
- (?<current>\*[[:blank:]]) | # Current branch (checked out in the current worktree)
694
- (?<worktree>\+[[:blank:]]) | # Branch checked out in a different worktree
695
- [[:blank:]]{2} # Branch not checked out
696
- )
697
-
698
- # The branch's full refname
699
- (?:
700
- (?<not_a_branch>\(not[[:blank:]]a[[:blank:]]branch\)) |
701
- (?:\(HEAD[[:blank:]]detached[[:blank:]]at[[:blank:]](?<detached_ref>[^)]+)\)) |
702
- (?<refname>[^[[:blank:]]]+)
703
- )
704
-
705
- # Optional symref
706
- # If this ref is a symbolic reference, this is the ref referenced
707
- (?:
708
- [[:blank:]]->[[:blank:]](?<symref>.*)
709
- )?
710
- $
711
- /x
712
-
713
704
  def branches_all
714
- lines = command_lines('branch', '-a')
715
- lines.each_with_index.filter_map do |line, index|
716
- parse_branch_line(line, index, lines)
717
- end
705
+ result = Git::Commands::Branch::List.new(self).call(all: true, format: Git::Parsers::Branch::FORMAT_STRING)
706
+ Git::Parsers::Branch.parse_list(result.stdout)
718
707
  end
719
708
 
720
709
  def worktrees_all
@@ -729,7 +718,7 @@ module Git
729
718
  # HEAD b8c63206f8d10f57892060375a86ae911fad356e
730
719
  # detached
731
720
  #
732
- command_lines('worktree', 'list', '--porcelain').each do |w|
721
+ Git::Commands::Worktree::List.new(self).call(porcelain: true).stdout.split("\n").each do |w|
733
722
  s = w.split
734
723
  directory = s[1] if s[0] == 'worktree'
735
724
  arr << [directory, s[1]] if s[0] == 'HEAD'
@@ -738,17 +727,19 @@ module Git
738
727
  end
739
728
 
740
729
  def worktree_add(dir, commitish = nil)
741
- return worktree_command('worktree', 'add', dir, commitish) unless commitish.nil?
742
-
743
- worktree_command('worktree', 'add', dir)
730
+ if commitish.nil?
731
+ Git::Commands::Worktree::Add.new(self).call(dir).stdout
732
+ else
733
+ Git::Commands::Worktree::Add.new(self).call(dir, commitish).stdout
734
+ end
744
735
  end
745
736
 
746
737
  def worktree_remove(dir)
747
- worktree_command('worktree', 'remove', dir)
738
+ Git::Commands::Worktree::Remove.new(self).call(dir).stdout
748
739
  end
749
740
 
750
741
  def worktree_prune
751
- worktree_command('worktree', 'prune')
742
+ Git::Commands::Worktree::Prune.new(self).call.stdout
752
743
  end
753
744
 
754
745
  def list_files(ref_dir)
@@ -785,7 +776,7 @@ module Git
785
776
  # @return [HeadState] the state and name of the current branch
786
777
  #
787
778
  def current_branch_state
788
- branch_name = command('branch', '--show-current')
779
+ branch_name = Git::Commands::Branch::ShowCurrent.new(self).call.stdout
789
780
  return HeadState.new(:detached, 'HEAD') if branch_name.empty?
790
781
 
791
782
  state = get_branch_state(branch_name)
@@ -793,39 +784,36 @@ module Git
793
784
  end
794
785
 
795
786
  def branch_current
796
- branch_name = command('branch', '--show-current')
797
- branch_name.empty? ? 'HEAD' : branch_name
787
+ result = Git::Commands::Branch::ShowCurrent.new(self).call
788
+ name = result.stdout.strip
789
+ name.empty? ? 'HEAD' : name
798
790
  end
799
791
 
800
792
  def branch_contains(commit, branch_name = '')
801
- command('branch', branch_name, '--contains', commit)
793
+ branch_name = branch_name.to_s
794
+ pattern = branch_name.empty? ? nil : branch_name
795
+ Git::Commands::Branch::List.new(self).call(*[pattern].compact, contains: commit, no_color: true).stdout
802
796
  end
803
797
 
804
- GREP_OPTION_MAP = [
805
- { keys: [:ignore_case], flag: '-i', type: :boolean },
806
- { keys: [:invert_match], flag: '-v', type: :boolean },
807
- { keys: [:extended_regexp], flag: '-E', type: :boolean },
808
- # For validation only, as these are handled manually
809
- { keys: [:object], type: :validate_only },
810
- { keys: [:path_limiter], type: :validate_only }
811
- ].freeze
798
+ GREP_ALLOWED_OPTS = %i[ignore_case i invert_match v extended_regexp E object path_limiter].freeze
812
799
 
813
- # returns hash
814
- # [tree-ish] = [[line_no, match], [line_no, match2]]
815
- # [tree-ish] = [[line_no, match], [line_no, match2]]
816
- def grep(string, opts = {})
817
- opts[:object] ||= 'HEAD'
818
- ArgsBuilder.validate!(opts, GREP_OPTION_MAP)
800
+ def grep(pattern, opts = {})
801
+ assert_valid_opts(opts, GREP_ALLOWED_OPTS)
819
802
 
820
- boolean_flags = build_args(opts, GREP_OPTION_MAP)
821
- args = ['-n', *boolean_flags, '-e', string, opts[:object]]
803
+ opts = normalize_grep_opts(opts)
804
+ object = opts.delete(:object) || 'HEAD'
805
+ result = Git::Commands::Grep.new(self).call(
806
+ object, pattern:, **opts, no_color: true, line_number: true, null: true
807
+ )
808
+ exitstatus = result.status.exitstatus
822
809
 
823
- if (limiter = opts[:path_limiter])
824
- args.push('--', *Array(limiter))
825
- end
810
+ # Exit status 1 with empty stderr means no lines matched (not an error)
811
+ return {} if exitstatus == 1 && result.stderr.empty?
812
+
813
+ # Exit status 1 with non-empty stderr is a real error (e.g. bad object reference)
814
+ raise Git::FailedError, result if exitstatus == 1
826
815
 
827
- lines = execute_grep_command(args)
828
- parse_grep_output(lines)
816
+ parse_grep_output(result.stdout)
829
817
  end
830
818
 
831
819
  # Validate that the given arguments cannot be mistaken for a command-line option
@@ -878,7 +866,24 @@ module Git
878
866
  raise ArgumentError, "Invalid #{arg_name}: must be a String, Pathname, or Array of Strings/Pathnames"
879
867
  end
880
868
 
869
+ # Allowed option keys for {#full_log_commits}
870
+ FULL_LOG_ALLOWED_OPTS = %i[count all cherry since until grep author between object path_limiter skip merges].freeze
871
+
872
+ # Allowed option keys for {#diff_full}
873
+ DIFF_FULL_ALLOWED_OPTS = %i[path_limiter].freeze
874
+
875
+ # Allowed option keys for {#diff_stats}
876
+ DIFF_STATS_ALLOWED_OPTS = %i[path_limiter].freeze
877
+
878
+ # Allowed option keys for {#diff_path_status}
879
+ DIFF_PATH_STATUS_ALLOWED_OPTS = %i[path_limiter path].freeze
880
+
881
881
  # Handle deprecated :path option in favor of :path_limiter
882
+ #
883
+ # @param opts [Hash] options hash that may contain :path or :path_limiter
884
+ #
885
+ # @return [String, Pathname, Array<String, Pathname>, nil] the resolved path limiter
886
+ #
882
887
  def handle_deprecated_path_option(opts)
883
888
  if opts.key?(:path_limiter)
884
889
  opts[:path_limiter]
@@ -890,74 +895,131 @@ module Git
890
895
  end
891
896
  end
892
897
 
893
- DIFF_FULL_OPTION_MAP = [
894
- { type: :static, flag: '-p' },
895
- { keys: [:path_limiter], type: :validate_only }
896
- ].freeze
898
+ # Validate that opts contains only allowed keys
899
+ #
900
+ # @param opts [Hash] options hash to validate
901
+ #
902
+ # @param allowed [Array<Symbol>] allowed option keys
903
+ #
904
+ # @raise [ArgumentError] if unknown keys are present
905
+ #
906
+ def assert_valid_opts(opts, allowed)
907
+ unknown = opts.keys - allowed
908
+ raise ArgumentError, "Unknown options: #{unknown.join(', ')}" if unknown.any?
909
+ end
897
910
 
911
+ # Show full diff patch output between commits or the working tree
912
+ #
913
+ # Delegates to {Git::Commands::Diff}.
914
+ #
915
+ # @param obj1 [String] first commit reference (default: 'HEAD')
916
+ #
917
+ # @param obj2 [String, nil] second commit reference (default: nil)
918
+ #
919
+ # @param opts [Hash] options
920
+ #
921
+ # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter (nil)
922
+ # pathspecs to limit the diff
923
+ #
924
+ # @return [String] the unified diff patch output
925
+ #
926
+ # @raise [Git::FailedError] if git returns exit code > 2
927
+ #
928
+ # @see Git::Commands::Diff
929
+ #
898
930
  def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
899
- assert_args_are_not_options('commit or commit range', obj1, obj2)
900
- ArgsBuilder.validate!(opts, DIFF_FULL_OPTION_MAP)
901
-
902
- args = build_args(opts, DIFF_FULL_OPTION_MAP)
903
- args.push(obj1, obj2).compact!
904
-
905
- if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
906
- args.push('--', *pathspecs)
907
- end
908
-
909
- command('diff', *args)
931
+ assert_valid_opts(opts, DIFF_FULL_ALLOWED_OPTS)
932
+ pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter')
933
+ result = Git::Commands::Diff.new(self).call(
934
+ *[obj1, obj2].compact,
935
+ patch: true, numstat: true, shortstat: true,
936
+ src_prefix: 'a/', dst_prefix: 'b/',
937
+ path: pathspecs
938
+ )
939
+ extract_patch_text(result.stdout)
910
940
  end
911
941
 
912
- DIFF_STATS_OPTION_MAP = [
913
- { type: :static, flag: '--numstat' },
914
- { keys: [:path_limiter], type: :validate_only }
915
- ].freeze
916
-
942
+ # Show numstat diff output between commits or the working tree
943
+ #
944
+ # Delegates to {Git::Commands::Diff}.
945
+ #
946
+ # @param obj1 [String] first commit reference (default: 'HEAD')
947
+ #
948
+ # @param obj2 [String, nil] second commit reference (default: nil)
949
+ #
950
+ # @param opts [Hash] options
951
+ #
952
+ # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter (nil)
953
+ # pathspecs to limit the diff
954
+ #
955
+ # @return [Hash] diff statistics with the shape:
956
+ # `{ total: { insertions:, deletions:, lines:, files: }, files: { ... } }`
957
+ #
958
+ # @raise [Git::FailedError] if git returns exit code > 2
959
+ #
960
+ # @see Git::Commands::Diff
961
+ #
917
962
  def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
918
- assert_args_are_not_options('commit or commit range', obj1, obj2)
919
- ArgsBuilder.validate!(opts, DIFF_STATS_OPTION_MAP)
920
-
921
- args = build_args(opts, DIFF_STATS_OPTION_MAP)
922
- args.push(obj1, obj2).compact!
923
-
924
- if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
925
- args.push('--', *pathspecs)
926
- end
927
-
928
- output_lines = command_lines('diff', *args)
963
+ assert_valid_opts(opts, DIFF_STATS_ALLOWED_OPTS)
964
+ pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter')
965
+ result = Git::Commands::Diff.new(self).call(
966
+ *[obj1, obj2].compact,
967
+ numstat: true, shortstat: true,
968
+ src_prefix: 'a/', dst_prefix: 'b/',
969
+ path: pathspecs
970
+ )
971
+ output_lines = extract_numstat_lines(result.stdout)
929
972
  parse_diff_stats_output(output_lines)
930
973
  end
931
974
 
932
- DIFF_PATH_STATUS_OPTION_MAP = [
933
- { type: :static, flag: '--name-status' },
934
- { keys: [:path_limiter], type: :validate_only },
935
- { keys: [:path], type: :validate_only }
936
- ].freeze
937
-
975
+ # Show path status (name-status) for diff between commits or the working tree
976
+ #
977
+ # Delegates to {Git::Commands::Diff} and extracts status letters and
978
+ # paths from the raw output lines.
979
+ #
980
+ # @param reference1 [String, nil] first commit reference (default: nil)
981
+ #
982
+ # @param reference2 [String, nil] second commit reference (default: nil)
983
+ #
984
+ # @param opts [Hash] options
985
+ #
986
+ # @option opts [String, Pathname, Array<String, Pathname>] :path_limiter (nil)
987
+ # pathspecs to limit the diff
988
+ #
989
+ # @option opts [String, Pathname, Array<String, Pathname>] :path (nil)
990
+ # deprecated; use :path_limiter instead
991
+ #
992
+ # @return [Hash] mapping of file paths to status letters
993
+ # (e.g. `{ "lib/foo.rb" => "M", "README.md" => "A" }`)
994
+ #
995
+ # @raise [Git::FailedError] if git returns exit code > 2
996
+ #
997
+ # @see Git::Commands::Diff
998
+ #
938
999
  def diff_path_status(reference1 = nil, reference2 = nil, opts = {})
939
- assert_args_are_not_options('commit or commit range', reference1, reference2)
940
- ArgsBuilder.validate!(opts, DIFF_PATH_STATUS_OPTION_MAP)
941
-
942
- args = build_args(opts, DIFF_PATH_STATUS_OPTION_MAP)
943
- args.push(reference1, reference2).compact!
1000
+ assert_valid_opts(opts, DIFF_PATH_STATUS_ALLOWED_OPTS)
944
1001
 
945
1002
  path_limiter = handle_deprecated_path_option(opts)
946
- if (pathspecs = normalize_pathspecs(path_limiter, 'path limiter'))
947
- args.push('--', *pathspecs)
948
- end
949
-
950
- parse_diff_path_status(args)
1003
+ pathspecs = normalize_pathspecs(path_limiter, 'path limiter')
1004
+ result = Git::Commands::Diff.new(self).call(
1005
+ *[reference1, reference2].compact,
1006
+ raw: true, numstat: true, shortstat: true,
1007
+ src_prefix: 'a/', dst_prefix: 'b/',
1008
+ path: pathspecs
1009
+ )
1010
+ extract_name_status_from_raw(result.stdout)
951
1011
  end
952
1012
 
953
1013
  # compares the index and the working directory
954
1014
  def diff_files
955
- diff_as_hash('diff-files')
1015
+ Git::Commands::Status.new(self).call
1016
+ parse_raw_diff_output(Git::Commands::DiffFiles.new(self).call.stdout)
956
1017
  end
957
1018
 
958
1019
  # compares the index and the repository
959
1020
  def diff_index(treeish)
960
- diff_as_hash('diff-index', treeish)
1021
+ Git::Commands::Status.new(self).call
1022
+ parse_raw_diff_output(Git::Commands::DiffIndex.new(self).call(treeish).stdout)
961
1023
  end
962
1024
 
963
1025
  # List all files that are in the index
@@ -975,7 +1037,7 @@ module Git
975
1037
  def ls_files(location = nil)
976
1038
  location ||= '.'
977
1039
  {}.tap do |files|
978
- command_lines('ls-files', '--stage', location).each do |line|
1040
+ Git::Commands::LsFiles.new(self).call(location, stage: true).stdout.split("\n").each do |line|
979
1041
  (info, file) = split_status_line(line)
980
1042
  (mode, sha, stage) = info.split
981
1043
  files[file] = {
@@ -1008,28 +1070,22 @@ module Git
1008
1070
  end
1009
1071
  end
1010
1072
 
1011
- LS_REMOTE_OPTION_MAP = [
1012
- { keys: [:refs], flag: '--refs', type: :boolean }
1013
- ].freeze
1014
-
1015
1073
  def ls_remote(location = nil, opts = {})
1016
- ArgsBuilder.validate!(opts, LS_REMOTE_OPTION_MAP)
1017
-
1018
- flags = build_args(opts, LS_REMOTE_OPTION_MAP)
1019
- positional_arg = location || '.'
1020
-
1021
- output_lines = command_lines('ls-remote', *flags, positional_arg)
1074
+ repository = location || '.'
1075
+ output_lines = Git::Commands::LsRemote.new(self).call(repository, **opts).stdout.split("\n")
1022
1076
  parse_ls_remote_output(output_lines)
1023
1077
  end
1024
1078
 
1025
1079
  def ignored_files
1026
- command_lines('ls-files', '--others', '-i', '--exclude-standard').map { |f| unescape_quoted_path(f) }
1080
+ Git::Commands::LsFiles.new(self).call(
1081
+ others: true, ignored: true, exclude_standard: true
1082
+ ).stdout.split("\n").map { |f| unescape_quoted_path(f) }
1027
1083
  end
1028
1084
 
1029
1085
  def untracked_files
1030
- command_lines('ls-files', '--others', '--exclude-standard', chdir: @git_work_dir).map do |f|
1031
- unescape_quoted_path(f)
1032
- end
1086
+ Git::Commands::LsFiles.new(self).call(
1087
+ others: true, exclude_standard: true, chdir: @git_work_dir
1088
+ ).stdout.split("\n").map { |f| unescape_quoted_path(f) }
1033
1089
  end
1034
1090
 
1035
1091
  def config_remote(name)
@@ -1041,19 +1097,25 @@ module Git
1041
1097
  end
1042
1098
 
1043
1099
  def config_get(name)
1044
- command('config', '--get', name, chdir: @git_dir)
1100
+ result = Git::Commands::ConfigOptionSyntax::Get.new(self).call(name)
1101
+ raise Git::FailedError, result if result.status.exitstatus != 0
1102
+
1103
+ result.stdout
1045
1104
  end
1046
1105
 
1047
1106
  def global_config_get(name)
1048
- command('config', '--global', '--get', name)
1107
+ result = Git::Commands::ConfigOptionSyntax::Get.new(self).call(name, global: true)
1108
+ raise Git::FailedError, result if result.status.exitstatus != 0
1109
+
1110
+ result.stdout
1049
1111
  end
1050
1112
 
1051
1113
  def config_list
1052
- parse_config_list command_lines('config', '--list', chdir: @git_dir)
1114
+ parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call.stdout.split("\n")
1053
1115
  end
1054
1116
 
1055
1117
  def global_config_list
1056
- parse_config_list command_lines('config', '--global', '--list')
1118
+ parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call(global: true).stdout.split("\n")
1057
1119
  end
1058
1120
 
1059
1121
  def parse_config_list(lines)
@@ -1066,7 +1128,7 @@ module Git
1066
1128
  end
1067
1129
 
1068
1130
  def parse_config(file)
1069
- parse_config_list command_lines('config', '--list', '--file', file)
1131
+ parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call(file: file).stdout.split("\n")
1070
1132
  end
1071
1133
 
1072
1134
  # Shows objects
@@ -1075,34 +1137,23 @@ module Git
1075
1137
  # @param [String|NilClass] path the path of the file to be shown
1076
1138
  # @return [String] the object information
1077
1139
  def show(objectish = nil, path = nil)
1078
- arr_opts = []
1079
-
1080
- arr_opts << (path ? "#{objectish}:#{path}" : objectish)
1081
-
1082
- command('show', *arr_opts.compact, chomp: false)
1140
+ object = path ? "#{objectish}:#{path}" : objectish
1141
+ Git::Commands::Show.new(self).call(*[object].compact).stdout
1083
1142
  end
1084
1143
 
1085
1144
  ## WRITE COMMANDS ##
1086
1145
 
1087
- CONFIG_SET_OPTION_MAP = [
1088
- { keys: [:file], flag: '--file', type: :valued_space }
1089
- ].freeze
1146
+ CONFIG_SET_ALLOWED_OPTS = %i[file].freeze
1090
1147
 
1091
1148
  def config_set(name, value, options = {})
1092
- ArgsBuilder.validate!(options, CONFIG_SET_OPTION_MAP)
1093
- flags = build_args(options, CONFIG_SET_OPTION_MAP)
1094
- command('config', *flags, name, value)
1149
+ assert_valid_opts(options, CONFIG_SET_ALLOWED_OPTS)
1150
+ Git::Commands::ConfigOptionSyntax::Set.new(self).call(name, value, **options.slice(*CONFIG_SET_ALLOWED_OPTS))
1095
1151
  end
1096
1152
 
1097
1153
  def global_config_set(name, value)
1098
- command('config', '--global', name, value)
1154
+ Git::Commands::ConfigOptionSyntax::Set.new(self).call(name, value, global: true)
1099
1155
  end
1100
1156
 
1101
- ADD_OPTION_MAP = [
1102
- { keys: [:all], flag: '--all', type: :boolean },
1103
- { keys: [:force], flag: '--force', type: :boolean }
1104
- ].freeze
1105
-
1106
1157
  # Update the index from the current worktree to prepare the for the next commit
1107
1158
  #
1108
1159
  # @example
@@ -1113,31 +1164,28 @@ module Git
1113
1164
  # @param [String, Array<String>] paths files to be added to the repository (relative to the worktree root)
1114
1165
  # @param [Hash] options
1115
1166
  #
1116
- # @option options [Boolean] :all Add, modify, and remove index entries to match the worktree
1117
- # @option options [Boolean] :force Allow adding otherwise ignored files
1167
+ # @option options [Boolean, nil] :all (nil) add, modify, and remove index entries to match the worktree
1168
+ # @option options [Boolean, nil] :force (nil) allow adding otherwise ignored files
1169
+ #
1170
+ # @return [String] the command output (typically empty on success)
1118
1171
  #
1119
1172
  def add(paths = '.', options = {})
1120
- args = build_args(options, ADD_OPTION_MAP)
1121
-
1122
- args << '--'
1123
- args.concat(Array(paths))
1124
-
1125
- command('add', *args)
1173
+ Git::Commands::Add.new(self).call(*Array(paths), **options).stdout
1126
1174
  end
1127
1175
 
1128
- RM_OPTION_MAP = [
1129
- { type: :static, flag: '-f' },
1130
- { keys: [:recursive], flag: '-r', type: :boolean },
1131
- { keys: [:cached], flag: '--cached', type: :boolean }
1132
- ].freeze
1133
-
1176
+ # Remove files from the working tree and from the index
1177
+ #
1178
+ # @param path [String, Array<String>] files or directories to remove
1179
+ # @param opts [Hash] command options
1180
+ #
1181
+ # @option opts [Boolean, nil] :force (nil) force removal, bypassing the up-to-date check; alias: `:f`
1182
+ # @option opts [Boolean, nil] :recursive (nil) remove directories and their contents recursively
1183
+ # @option opts [Boolean, nil] :cached (nil) only remove from the index, keeping working tree files
1184
+ #
1185
+ # @return [String] the command output
1186
+ #
1134
1187
  def rm(path = '.', opts = {})
1135
- args = build_args(opts, RM_OPTION_MAP)
1136
-
1137
- args << '--'
1138
- args.concat(Array(path))
1139
-
1140
- command('rm', *args)
1188
+ Git::Commands::Rm.new(self).call(*Array(path), **opts).stdout
1141
1189
  end
1142
1190
 
1143
1191
  # Returns true if the repository is empty (meaning it has no commits)
@@ -1145,7 +1193,7 @@ module Git
1145
1193
  # @return [Boolean]
1146
1194
  #
1147
1195
  def empty?
1148
- command('rev-parse', '--verify', 'HEAD')
1196
+ Git::Commands::RevParse.new(self).call('HEAD', verify: true)
1149
1197
  false
1150
1198
  rescue Git::FailedError => e
1151
1199
  raise unless e.result.status.exitstatus == 128 &&
@@ -1154,27 +1202,6 @@ module Git
1154
1202
  true
1155
1203
  end
1156
1204
 
1157
- COMMIT_OPTION_MAP = [
1158
- { keys: %i[add_all all], flag: '--all', type: :boolean },
1159
- { keys: [:allow_empty], flag: '--allow-empty', type: :boolean },
1160
- { keys: [:no_verify], flag: '--no-verify', type: :boolean },
1161
- { keys: [:allow_empty_message], flag: '--allow-empty-message', type: :boolean },
1162
- { keys: [:author], flag: '--author', type: :valued_equals },
1163
- { keys: [:message], flag: '--message', type: :valued_equals },
1164
- { keys: [:no_gpg_sign], flag: '--no-gpg-sign', type: :boolean },
1165
- { keys: [:date], flag: '--date', type: :valued_equals, validator: ->(v) { v.is_a?(String) } },
1166
- { keys: [:amend], type: :custom, builder: ->(value) { ['--amend', '--no-edit'] if value } },
1167
- {
1168
- keys: [:gpg_sign],
1169
- type: :custom,
1170
- builder: lambda { |value|
1171
- if value
1172
- value == true ? '--gpg-sign' : "--gpg-sign=#{value}"
1173
- end
1174
- }
1175
- }
1176
- ].freeze
1177
-
1178
1205
  # Takes the commit message with the options and executes the commit command
1179
1206
  #
1180
1207
  # accepts options:
@@ -1190,176 +1217,284 @@ module Git
1190
1217
  #
1191
1218
  # @param [String] message the commit message to be used
1192
1219
  # @param [Hash] opts the commit options to be used
1193
-
1220
+ #
1194
1221
  def commit(message, opts = {})
1195
- opts[:message] = message if message # Handle message arg for backward compatibility
1196
-
1197
- # Perform cross-option validation before building args
1198
- raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign' if opts[:gpg_sign] && opts[:no_gpg_sign]
1199
-
1200
- ArgsBuilder.validate!(opts, COMMIT_OPTION_MAP)
1201
-
1202
- args = build_args(opts, COMMIT_OPTION_MAP)
1203
- command('commit', *args)
1222
+ opts = opts.merge(message: message) if message
1223
+ deprecate_commit_add_all_option!(opts)
1224
+ Git::Commands::Commit.new(self).call(no_edit: true, **opts).stdout
1204
1225
  end
1205
- RESET_OPTION_MAP = [
1206
- { keys: [:hard], flag: '--hard', type: :boolean }
1207
- ].freeze
1208
1226
 
1209
- def reset(commit, opts = {})
1210
- args = build_args(opts, RESET_OPTION_MAP)
1211
- args << commit if commit
1212
- command('reset', *args)
1227
+ # @return [String] the command output
1228
+ #
1229
+ def reset(commit = nil, opts = {})
1230
+ Git::Commands::Reset.new(self).call(commit, **opts).stdout
1213
1231
  end
1214
1232
 
1215
- CLEAN_OPTION_MAP = [
1216
- { keys: [:force], flag: '--force', type: :boolean },
1217
- { keys: [:ff], flag: '-ff', type: :boolean },
1218
- { keys: [:d], flag: '-d', type: :boolean },
1219
- { keys: [:x], flag: '-x', type: :boolean }
1220
- ].freeze
1221
-
1233
+ # @return [String] the command output
1234
+ #
1222
1235
  def clean(opts = {})
1223
- args = build_args(opts, CLEAN_OPTION_MAP)
1224
- command('clean', *args)
1236
+ opts = migrate_clean_legacy_options(opts)
1237
+ Git::Commands::Clean.new(self).call(**opts).stdout
1225
1238
  end
1226
1239
 
1227
- REVERT_OPTION_MAP = [
1228
- { keys: [:no_edit], flag: '--no-edit', type: :boolean }
1229
- ].freeze
1240
+ REVERT_ALLOWED_OPTS = %i[no_edit].freeze
1230
1241
 
1231
1242
  def revert(commitish, opts = {})
1232
- # Forcing --no-edit as default since it's not an interactive session.
1243
+ assert_valid_opts(opts, REVERT_ALLOWED_OPTS)
1233
1244
  opts = { no_edit: true }.merge(opts)
1234
-
1235
- args = build_args(opts, REVERT_OPTION_MAP)
1236
- args << commitish
1237
-
1238
- command('revert', *args)
1245
+ Git::Commands::Revert::Start.new(self).call(commitish, **opts).stdout
1239
1246
  end
1240
1247
 
1241
1248
  def apply(patch_file)
1242
- arr_opts = []
1243
- arr_opts << '--' << patch_file if patch_file
1244
- command('apply', *arr_opts)
1249
+ Git::Commands::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1245
1250
  end
1246
1251
 
1247
1252
  def apply_mail(patch_file)
1248
- arr_opts = []
1249
- arr_opts << '--' << patch_file if patch_file
1250
- command('am', *arr_opts)
1253
+ Git::Commands::Am::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1251
1254
  end
1252
1255
 
1256
+ # Returns all stash entries as an array of index and message pairs
1257
+ #
1258
+ # List all stash entries in the repository ordered from oldest to newest
1259
+ #
1260
+ # The index is a sequential number starting from 0 for the oldest stash, and the
1261
+ # message is the description of the stash entry.
1262
+ #
1263
+ # @example List all stashes (oldest first)
1264
+ # lib.stashes_all # => [[0, "Fix bug"], [1, "Add feature"]]
1265
+ #
1266
+ # @return [Array<Array(Integer, String)>] array of [index, message] pairs where
1267
+ # index is the sequential position (0 is oldest) and message is the stash description
1268
+ #
1269
+ # @see https://git-scm.com/docs/git-stash git-stash documentation
1270
+ #
1253
1271
  def stashes_all
1254
- stash_log_lines.each_with_index.map do |line, index|
1255
- parse_stash_log_line(line, index)
1256
- end
1272
+ result = Git::Commands::Stash::List.new(self).call
1273
+ stashes = Git::Parsers::Stash.parse_list(result.stdout)
1274
+ stashes.reverse.each_with_index.map { |info, i| stash_info_to_legacy(info, i) }
1257
1275
  end
1258
1276
 
1259
- def stash_save(message)
1260
- output = command('stash', 'save', message)
1261
- output =~ /HEAD is now at/
1277
+ # Save the current working directory and index state to a new stash
1278
+ #
1279
+ # This method preserves v4.0.0 backward compatibility by returning a truthy/falsy
1280
+ # value indicating whether a stash was created.
1281
+ #
1282
+ # @param message [String] the stash message
1283
+ #
1284
+ # @return [Boolean] true if changes were stashed, false if there were no local changes to save
1285
+ #
1286
+ # @example Save current changes
1287
+ # lib.stash_save('WIP: feature work')
1288
+ #
1289
+ # @see https://git-scm.com/docs/git-stash git-stash documentation
1290
+ #
1291
+ def stash_save(message) # rubocop:disable Naming/PredicateMethod
1292
+ result = Git::Commands::Stash::Push.new(self).call(message: message)
1293
+ !result.stdout.include?('No local changes to save')
1262
1294
  end
1263
1295
 
1296
+ # Apply a stash to the working directory
1297
+ #
1298
+ # This method preserves v4.0.0 backward compatibility by returning the command output.
1299
+ #
1300
+ # @param id [String, Integer, nil] the stash identifier (e.g., 'stash@\\{0}', 0) or nil for latest
1301
+ #
1302
+ # @return [String] the output from the git stash apply command
1303
+ #
1304
+ # @example Apply the latest stash
1305
+ # lib.stash_apply
1306
+ #
1307
+ # @example Apply a specific stash
1308
+ # lib.stash_apply('stash@{1}')
1309
+ #
1310
+ # @see https://git-scm.com/docs/git-stash git-stash documentation
1311
+ #
1264
1312
  def stash_apply(id = nil)
1265
- if id
1266
- command('stash', 'apply', id)
1267
- else
1268
- command('stash', 'apply')
1269
- end
1313
+ result = Git::Commands::Stash::Apply.new(self).call(id)
1314
+ result.stdout
1270
1315
  end
1271
1316
 
1317
+ # Remove all stash entries
1318
+ #
1319
+ # This method preserves v4.0.0 backward compatibility by returning the command output.
1320
+ #
1321
+ # @return [String] the output from the git stash clear command
1322
+ #
1323
+ # @example Clear all stashes
1324
+ # lib.stash_clear
1325
+ #
1326
+ # @see https://git-scm.com/docs/git-stash git-stash documentation
1327
+ #
1272
1328
  def stash_clear
1273
- command('stash', 'clear')
1329
+ result = Git::Commands::Stash::Clear.new(self).call
1330
+ result.stdout
1274
1331
  end
1275
1332
 
1276
- def stash_list
1277
- command('stash', 'list')
1333
+ # List all stash entries in standard git stash list format
1334
+ #
1335
+ # This method preserves v4.0.0 backward compatibility by returning a formatted
1336
+ # string matching the output of `git stash list`.
1337
+ #
1338
+ # @return [String] newline-separated list of stash entries in the format
1339
+ # "stash@\\{n}: <message>", or an empty string if no stashes exist
1340
+ #
1341
+ # @example List all stashes
1342
+ # lib.stash_list # => "stash@\\{0}: On main: WIP\nstash@\\{1}: On feature: test"
1343
+ #
1344
+ # @see https://git-scm.com/docs/git-stash git-stash documentation
1345
+ #
1346
+ def stash_list
1347
+ result = Git::Commands::Stash::List.new(self).call
1348
+ stashes = Git::Parsers::Stash.parse_list(result.stdout)
1349
+ stashes.map { |info| "#{info.name}: #{info.message}" }.join("\n")
1278
1350
  end
1279
1351
 
1280
- def branch_new(branch)
1281
- command('branch', branch)
1352
+ # Create a new branch
1353
+ #
1354
+ # @param branch [String] the name of the branch to create
1355
+ # @param start_point [String, nil] the commit, branch, or tag to start the new branch from
1356
+ # @param options [Hash] command options (see {Git::Commands::Branch::Create#call})
1357
+ #
1358
+ # @return [nil]
1359
+ #
1360
+ def branch_new(branch, start_point = nil, options = {})
1361
+ Git::Commands::Branch::Create.new(self).call(branch, start_point, **options)
1362
+ nil
1282
1363
  end
1283
1364
 
1284
- def branch_delete(branch)
1285
- command('branch', '-D', branch)
1286
- end
1365
+ # Delete one or more branches
1366
+ #
1367
+ # @param branches [Array<String>] the name(s) of the branch(es) to delete
1368
+ # @param options [Hash] command options (see {Git::Commands::Branch::Delete#call})
1369
+ # @option options [Boolean, nil] :force (nil) allow deleting unmerged branches (defaults to `true` when not given)
1370
+ # @option options [Boolean, nil] :remotes (nil) delete remote-tracking branches
1371
+ #
1372
+ # @return [String] newline-separated list of "Deleted branch <name> (was <sha>)." messages
1373
+ #
1374
+ # @raise [Git::Error] if any branch fails to delete
1375
+ #
1376
+ def branch_delete(*branches, **options)
1377
+ options = { force: true }.merge(options)
1378
+ result = Git::Commands::Branch::Delete.new(self).call(*branches, **options)
1287
1379
 
1288
- CHECKOUT_OPTION_MAP = [
1289
- { keys: %i[force f], flag: '--force', type: :boolean },
1290
- { keys: %i[new_branch b], type: :validate_only },
1291
- { keys: [:start_point], type: :validate_only }
1292
- ].freeze
1380
+ raise Git::Error, result.stderr.strip unless result.status.success?
1381
+
1382
+ result.stdout.strip
1383
+ end
1293
1384
 
1294
1385
  # Runs checkout command to checkout or create branch
1295
1386
  #
1296
1387
  # accepts options:
1297
- # :new_branch
1298
- # :force
1299
- # :start_point
1388
+ # :new_branch / :b - create a new branch with the given name (true = legacy, string = new)
1389
+ # :force / :f - proceed even with uncommitted changes
1390
+ # :start_point - start the new branch at this commit (used with :new_branch in legacy mode)
1391
+ #
1392
+ # @param [String] branch the branch to checkout, or nil
1393
+ # @param [Hash] opts options for the checkout command
1394
+ # @return [String] the command output
1300
1395
  #
1301
- # @param [String] branch
1302
- # @param [Hash] opts
1303
1396
  def checkout(branch = nil, opts = {})
1304
1397
  if branch.is_a?(Hash) && opts.empty?
1305
1398
  opts = branch
1306
1399
  branch = nil
1307
1400
  end
1308
- ArgsBuilder.validate!(opts, CHECKOUT_OPTION_MAP)
1309
1401
 
1310
- flags = build_args(opts, CHECKOUT_OPTION_MAP)
1311
- positional_args = build_checkout_positional_args(branch, opts)
1402
+ target, translated_opts = translate_checkout_opts(branch, opts)
1403
+ Git::Commands::Checkout::Branch.new(self).call(target, **translated_opts).stdout
1404
+ end
1312
1405
 
1313
- command('checkout', *flags, *positional_args)
1406
+ # Translates legacy checkout options to the new command interface.
1407
+ # Legacy: checkout('branch', new_branch: true, start_point: 'main')
1408
+ # New: checkout('main', b: 'branch')
1409
+ def translate_checkout_opts(branch, opts)
1410
+ if opts[:new_branch] == true || opts[:b] == true
1411
+ [opts[:start_point], opts.except(:new_branch, :b, :start_point).merge(b: branch)]
1412
+ elsif opts[:new_branch].is_a?(String)
1413
+ [branch, opts.except(:new_branch).merge(b: opts[:new_branch])]
1414
+ else
1415
+ [branch, opts]
1416
+ end
1314
1417
  end
1418
+ private :translate_checkout_opts
1315
1419
 
1420
+ # Checkout a specific version of a file
1421
+ #
1422
+ # @param version [String] the tree-ish (commit, branch, tag) to restore from
1423
+ # @param file [String] the file path to restore
1424
+ # @return [String] the command output
1425
+ #
1316
1426
  def checkout_file(version, file)
1317
- arr_opts = []
1318
- arr_opts << version
1319
- arr_opts << file
1320
- command('checkout', *arr_opts)
1427
+ Git::Commands::Checkout::Files.new(self).call(version, pathspec: [file]).stdout
1321
1428
  end
1322
1429
 
1323
- MERGE_OPTION_MAP = [
1324
- { keys: [:no_commit], flag: '--no-commit', type: :boolean },
1325
- { keys: [:no_ff], flag: '--no-ff', type: :boolean },
1326
- { keys: [:m], flag: '-m', type: :valued_space }
1327
- ].freeze
1328
-
1430
+ # Merge one or more branches into the current branch
1431
+ #
1432
+ # @param branch [String, Array<String>] branch name(s) to merge
1433
+ # @param message [String, nil] commit message for merge commit
1434
+ # @param opts [Hash] merge options
1435
+ #
1436
+ # @option opts [Boolean, nil] :no_commit (nil) stop before creating merge commit
1437
+ # (deprecated: use no_commit: true instead)
1438
+ # @option opts [Boolean, nil] :no_ff (nil) create merge commit even for fast-forward
1439
+ # (deprecated: use no_ff: true instead)
1440
+ # @option opts [String] :m (nil) commit message (deprecated: use message: option)
1441
+ # @option opts [Boolean, nil] :commit (nil) true for --commit (`--commit`)
1442
+ # @option opts [Boolean, nil] :ff (nil) true for --ff (`--ff`)
1443
+ # @option opts [Boolean, nil] :ff_only (nil) only merge if fast-forward possible
1444
+ # @option opts [Boolean, nil] :squash (nil) squash commits into single commit
1445
+ # @option opts [String] :message (nil) commit message
1446
+ # @option opts [String] :strategy (nil) merge strategy (e.g., 'ort', 'ours')
1447
+ # @option opts [String, Array<String>] :strategy_option (nil) strategy-specific options
1448
+ # @option opts [Boolean, nil] :allow_unrelated_histories (nil) allow merging unrelated histories
1449
+ #
1450
+ # @return [String] the command output
1451
+ #
1329
1452
  def merge(branch, message = nil, opts = {})
1330
- # For backward compatibility, treat the message arg as the :m option.
1331
- opts[:m] = message if message
1332
- ArgsBuilder.validate!(opts, MERGE_OPTION_MAP)
1453
+ # Handle legacy positional message argument
1454
+ opts = opts.merge(message: message) if message
1333
1455
 
1334
- args = build_args(opts, MERGE_OPTION_MAP)
1335
- args.concat(Array(branch))
1456
+ # Map legacy option names to new interface
1457
+ opts = translate_merge_options(opts)
1336
1458
 
1337
- command('merge', *args)
1459
+ Git::Commands::Merge::Start.new(self).call(*Array(branch), no_edit: true, **opts).stdout
1338
1460
  end
1339
1461
 
1340
- MERGE_BASE_OPTION_MAP = [
1341
- { keys: [:octopus], flag: '--octopus', type: :boolean },
1342
- { keys: [:independent], flag: '--independent', type: :boolean },
1343
- { keys: [:fork_point], flag: '--fork-point', type: :boolean },
1344
- { keys: [:all], flag: '--all', type: :boolean }
1345
- ].freeze
1346
-
1462
+ # Find common ancestor commit(s) for merge
1463
+ #
1464
+ # @overload merge_base(*commits, options = {})
1465
+ #
1466
+ # @param commits [Array<String>] commits to find common ancestor(s) of
1467
+ #
1468
+ # @param options [Hash] merge-base options
1469
+ #
1470
+ # @option options [Boolean, nil] :octopus (nil) compute best ancestor for n-way merge
1471
+ # @option options [Boolean, nil] :independent (nil) list commits not reachable from others
1472
+ # @option options [Boolean, nil] :fork_point (nil) find fork point
1473
+ # @option options [Boolean, nil] :all (nil) output all merge bases
1474
+ #
1475
+ # @return [Array<String>] array of commit SHAs
1476
+ #
1347
1477
  def merge_base(*args)
1348
1478
  opts = args.last.is_a?(Hash) ? args.pop : {}
1349
- ArgsBuilder.validate!(opts, MERGE_BASE_OPTION_MAP)
1350
-
1351
- flags = build_args(opts, MERGE_BASE_OPTION_MAP)
1352
- command_args = flags + args
1353
-
1354
- command('merge-base', *command_args).lines.map(&:strip)
1479
+ result = Git::Commands::MergeBase.new(self).call(*args, **opts)
1480
+ result.stdout.lines.map(&:strip).reject(&:empty?)
1355
1481
  end
1356
1482
 
1483
+ # List paths that remain unmerged after a failed or partial merge
1484
+ #
1485
+ # Delegates to {Git::Commands::Diff}.
1486
+ #
1487
+ # @return [Array<String>] paths of files with unresolved merge conflicts
1488
+ #
1489
+ # @raise [Git::FailedError] if git returns exit code > 2
1490
+ #
1491
+ # @see Git::Commands::Diff
1492
+ #
1357
1493
  def unmerged
1358
- unmerged = []
1359
- command_lines('diff', '--cached').each do |line|
1360
- unmerged << ::Regexp.last_match(1) if line =~ /^\* Unmerged path (.*)/
1494
+ result = Git::Commands::Diff.new(self).call(cached: true)
1495
+ result.stdout.split("\n").filter_map do |line|
1496
+ ::Regexp.last_match(1) if line =~ /^\* Unmerged path (.*)/
1361
1497
  end
1362
- unmerged
1363
1498
  end
1364
1499
 
1365
1500
  def conflicts # :yields: file, your, their
@@ -1375,416 +1510,961 @@ module Git
1375
1510
  end
1376
1511
  end
1377
1512
 
1378
- REMOTE_ADD_OPTION_MAP = [
1379
- { keys: %i[with_fetch fetch], flag: '-f', type: :boolean },
1380
- { keys: [:track], flag: '-t', type: :valued_space }
1381
- ].freeze
1382
-
1383
1513
  def remote_add(name, url, opts = {})
1384
- ArgsBuilder.validate!(opts, REMOTE_ADD_OPTION_MAP)
1385
-
1386
- flags = build_args(opts, REMOTE_ADD_OPTION_MAP)
1387
- positional_args = ['--', name, url]
1388
- command_args = ['add'] + flags + positional_args
1514
+ translated_opts = opts.dup
1515
+ translated_opts[:fetch] = translated_opts.delete(:with_fetch) if translated_opts.key?(:with_fetch)
1389
1516
 
1390
- command('remote', *command_args)
1517
+ Git::Commands::Remote::Add.new(self).call(name, url, **translated_opts)
1391
1518
  end
1392
1519
 
1393
- REMOTE_SET_BRANCHES_OPTION_MAP = [
1394
- { keys: [:add], flag: '--add', type: :boolean }
1395
- ].freeze
1396
-
1397
1520
  def remote_set_branches(name, branches, opts = {})
1398
- ArgsBuilder.validate!(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1399
-
1400
- flags = build_args(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1401
- branch_args = Array(branches).flatten
1402
- command_args = ['set-branches'] + flags + [name] + branch_args
1403
-
1404
- command('remote', *command_args)
1521
+ Git::Commands::Remote::SetBranches.new(self).call(name, *Array(branches).flatten, **opts)
1405
1522
  end
1406
1523
 
1407
- def remote_set_url(name, url)
1408
- arr_opts = ['set-url']
1409
- arr_opts << name
1410
- arr_opts << url
1411
-
1412
- command('remote', *arr_opts)
1524
+ def remote_set_url(name, url, opts = {})
1525
+ Git::Commands::Remote::SetUrl.new(self).call(name, url, **opts)
1413
1526
  end
1414
1527
 
1415
1528
  def remote_remove(name)
1416
- command('remote', 'rm', name)
1529
+ Git::Commands::Remote::Remove.new(self).call(name)
1417
1530
  end
1418
1531
 
1419
1532
  def remotes
1420
- command_lines('remote')
1533
+ Git::Commands::Remote::List.new(self).call.stdout.split("\n")
1421
1534
  end
1422
1535
 
1536
+ # List all tags in the repository
1537
+ #
1538
+ # @see https://git-scm.com/docs/git-tag git-tag
1539
+ #
1540
+ # @return [Array<String>] tag names
1541
+ #
1423
1542
  def tags
1424
- command_lines('tag')
1543
+ result = Git::Commands::Tag::List.new(self).call(format: Git::Parsers::Tag::FORMAT_STRING)
1544
+ Git::Parsers::Tag.parse_list(result.stdout).map(&:name)
1425
1545
  end
1426
1546
 
1427
- TAG_OPTION_MAP = [
1428
- { keys: %i[force f], flag: '-f', type: :boolean },
1429
- { keys: %i[annotate a], flag: '-a', type: :boolean },
1430
- { keys: %i[sign s], flag: '-s', type: :boolean },
1431
- { keys: %i[delete d], flag: '-d', type: :boolean },
1432
- { keys: %i[message m], flag: '-m', type: :valued_space }
1433
- ].freeze
1434
-
1547
+ # Create or delete a tag
1548
+ #
1549
+ # When the `:d` or `:delete` option is set, deletes the named tag.
1550
+ # Otherwise, creates a new tag pointing at HEAD or the specified target.
1551
+ #
1552
+ # @see https://git-scm.com/docs/git-tag git-tag
1553
+ #
1554
+ # @overload tag(name, target, opts = {})
1555
+ #
1556
+ # Create a tag on the specified target
1557
+ #
1558
+ # @param name [String] the tag name to create
1559
+ #
1560
+ # @param target [String] the commit or object to tag
1561
+ #
1562
+ # @param opts [Hash] options for creating the tag
1563
+ #
1564
+ # @option opts [Boolean, nil] :annotate (nil) create an unsigned, annotated tag object.
1565
+ # Requires `:message` or `:file`.
1566
+ #
1567
+ # Alias: `:a`
1568
+ #
1569
+ # @option opts [Boolean, nil] :sign (nil) create a GPG-signed tag. Requires `:message` or `:file`.
1570
+ #
1571
+ # Alias: `:s`
1572
+ #
1573
+ # @option opts [Boolean, nil] :force (nil) replace an existing tag with the given name.
1574
+ #
1575
+ # Alias: `:f`
1576
+ #
1577
+ # @option opts [String] :message (nil) use the given string as the tag message.
1578
+ # Implies annotated tag if none of `:annotate`, `:sign`, or `:local_user` is given.
1579
+ #
1580
+ # Alias: `:m`
1581
+ #
1582
+ # @overload tag(name, opts = {})
1583
+ #
1584
+ # Create a lightweight tag on HEAD
1585
+ #
1586
+ # @param name [String] the tag name to create
1587
+ #
1588
+ # @param opts [Hash] options for creating the tag
1589
+ #
1590
+ # @option opts [Boolean, nil] :annotate (nil) create an unsigned, annotated tag object.
1591
+ # Requires `:message` or `:file`.
1592
+ #
1593
+ # Alias: `:a`
1594
+ #
1595
+ # @option opts [Boolean, nil] :sign (nil) create a GPG-signed tag. Requires `:message` or `:file`.
1596
+ #
1597
+ # Alias: `:s`
1598
+ #
1599
+ # @option opts [Boolean, nil] :force (nil) replace an existing tag with the given name.
1600
+ #
1601
+ # Alias: `:f`
1602
+ #
1603
+ # @option opts [String] :message (nil) use the given string as the tag message.
1604
+ # Implies annotated tag if none of `:annotate`, `:sign`, or `:local_user` is given.
1605
+ #
1606
+ # Alias: `:m`
1607
+ #
1608
+ # @overload tag(name, opts = {})
1609
+ #
1610
+ # Delete the named tag
1611
+ #
1612
+ # @param name [String] the tag name to delete
1613
+ #
1614
+ # @param opts [Hash] options
1615
+ #
1616
+ # @option opts [Boolean, nil] :delete (nil) delete the named tag.
1617
+ #
1618
+ # Alias: `:d`
1619
+ #
1620
+ # @return [String] command output
1621
+ #
1622
+ # @raise [ArgumentError] if creating an annotated or signed tag without a message
1623
+ #
1624
+ # @raise [Git::FailedError] if the tag already exists (without `:force`) or if
1625
+ # the tag to delete does not exist
1626
+ #
1435
1627
  def tag(name, *args)
1436
1628
  opts = args.last.is_a?(Hash) ? args.pop : {}
1437
1629
  target = args.first
1438
1630
 
1439
- validate_tag_options!(opts)
1440
- ArgsBuilder.validate!(opts, TAG_OPTION_MAP)
1631
+ if opts[:d] || opts[:delete]
1632
+ delete_tag(name)
1633
+ else
1634
+ validate_tag_options!(opts)
1635
+ create_tag(name, target, opts)
1636
+ end
1637
+ end
1638
+
1639
+ def fetch(remote, opts)
1640
+ opts = opts.dup
1641
+ refspecs = Array(opts.delete(:ref)).compact
1642
+ positionals = [*([remote] if remote), *refspecs]
1643
+ Git::Commands::Fetch.new(self).call(*positionals, **opts, merge: true).stdout
1644
+ end
1645
+
1646
+ PUSH_ALLOWED_OPTS = %i[mirror delete force f push_option all tags].freeze
1647
+
1648
+ # Push refs to a remote repository
1649
+ #
1650
+ # @overload push(options = {})
1651
+ # Push using the current branch's default remote and push configuration
1652
+ #
1653
+ # @param options [Hash] push options
1654
+ #
1655
+ # @option options [Boolean, nil] :all (nil) push all branches
1656
+ #
1657
+ # @option options [Boolean, nil] :mirror (nil) push all refs
1658
+ #
1659
+ # @option options [Boolean, nil] :tags (nil) push all tags
1660
+ #
1661
+ # @option options [Boolean, nil] :force (nil) force updates
1662
+ #
1663
+ # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1664
+ #
1665
+ # @option options [String, Array<String>] :push_option (nil) server-side push option values
1666
+ #
1667
+ # @return [String] the stdout from the final `git push` invocation
1668
+ #
1669
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1670
+ #
1671
+ # @overload push(remote, options = {})
1672
+ # Push to the given remote using the current branch's default push configuration
1673
+ #
1674
+ # @param remote [String] the remote name or URL to push to
1675
+ #
1676
+ # @param options [Hash] push options
1677
+ #
1678
+ # @option options [Boolean, nil] :all (nil) push all branches
1679
+ #
1680
+ # @option options [Boolean, nil] :mirror (nil) push all refs
1681
+ #
1682
+ # @option options [Boolean, nil] :tags (nil) push all tags
1683
+ #
1684
+ # @option options [Boolean, nil] :force (nil) force updates
1685
+ #
1686
+ # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1687
+ #
1688
+ # @option options [String, Array<String>] :push_option (nil) server-side push option values
1689
+ #
1690
+ # @return [String] the stdout from the final `git push` invocation
1691
+ #
1692
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1693
+ #
1694
+ # @overload push(remote, branch, options = {})
1695
+ # Push a branch or refspec to the given remote
1696
+ #
1697
+ # @param remote [String] the remote name or URL to push to
1698
+ #
1699
+ # @param branch [String] the branch name or refspec to push
1700
+ #
1701
+ # @param options [Hash] push options
1702
+ #
1703
+ # @option options [Boolean, nil] :all (nil) push all branches
1704
+ #
1705
+ # @option options [Boolean, nil] :mirror (nil) push all refs
1706
+ #
1707
+ # @option options [Boolean, nil] :tags (nil) push all tags
1708
+ #
1709
+ # @option options [Boolean, nil] :force (nil) force updates
1710
+ #
1711
+ # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1712
+ #
1713
+ # @option options [String, Array<String>] :push_option (nil) server-side push option values
1714
+ #
1715
+ # @return [String] the stdout from the final `git push` invocation
1716
+ #
1717
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1718
+ #
1719
+ # @raise [ArgumentError] if `remote` is nil
1720
+ #
1721
+ # @overload push(remote, branch, tags)
1722
+ # Backward-compatible shorthand for `push(remote, branch, tags: tags)`
1723
+ #
1724
+ # @param remote [String] the remote name or URL to push to
1725
+ #
1726
+ # @param branch [String] the branch name or refspec to push
1727
+ #
1728
+ # @param tags [Boolean] whether to push all tags
1729
+ #
1730
+ # @return [String] the stdout from the final `git push` invocation
1731
+ #
1732
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1733
+ #
1734
+ # @raise [ArgumentError] if `remote` is nil
1735
+ #
1736
+ def push(remote = nil, branch = nil, opts = nil)
1737
+ remote, branch, opts = normalize_push_args(remote, branch, opts)
1738
+ validate_push_args!(remote, branch, opts)
1441
1739
 
1442
- flags = build_args(opts, TAG_OPTION_MAP)
1443
- positional_args = [name, target].compact
1740
+ first_result = push_refs(remote, branch, opts)
1741
+ return first_result.stdout unless push_tags_separately?(opts)
1444
1742
 
1445
- command('tag', *flags, *positional_args)
1743
+ push_tags(remote, opts).stdout
1446
1744
  end
1447
1745
 
1448
- FETCH_OPTION_MAP = [
1449
- { keys: [:all], flag: '--all', type: :boolean },
1450
- { keys: %i[tags t], flag: '--tags', type: :boolean },
1451
- { keys: %i[prune p], flag: '--prune', type: :boolean },
1452
- { keys: %i[prune-tags P], flag: '--prune-tags', type: :boolean },
1453
- { keys: %i[force f], flag: '--force', type: :boolean },
1454
- { keys: %i[update-head-ok u], flag: '--update-head-ok', type: :boolean },
1455
- { keys: [:unshallow], flag: '--unshallow', type: :boolean },
1456
- { keys: [:depth], flag: '--depth', type: :valued_space },
1457
- { keys: [:ref], type: :validate_only }
1458
- ].freeze
1746
+ PULL_ALLOWED_OPTS = %i[allow_unrelated_histories].freeze
1459
1747
 
1460
- def fetch(remote, opts)
1461
- ArgsBuilder.validate!(opts, FETCH_OPTION_MAP)
1462
- args = build_args(opts, FETCH_OPTION_MAP)
1748
+ def pull(remote = nil, branch = nil, opts = {})
1749
+ raise ArgumentError, 'You must specify a remote if a branch is specified' if remote.nil? && !branch.nil?
1750
+
1751
+ assert_valid_opts(opts, PULL_ALLOWED_OPTS)
1752
+ allowed_opts = opts.slice(*PULL_ALLOWED_OPTS)
1753
+ positional_args = [remote, branch].compact
1754
+ Git::Commands::Pull.new(self).call(*positional_args, no_edit: true, no_progress: true, **allowed_opts).stdout
1755
+ end
1463
1756
 
1464
- if remote || opts[:ref]
1465
- args << '--'
1466
- args << remote if remote
1467
- args << opts[:ref] if opts[:ref]
1757
+ # Return the SHA of a tag reference
1758
+ #
1759
+ # Looks up the tag first in the local refs directory, then falls back to
1760
+ # `git show-ref`. Returns an empty string if the tag does not exist.
1761
+ #
1762
+ # @param tag_name [String] the tag name to look up
1763
+ #
1764
+ # @return [String] the SHA of the tag, or an empty string if not found
1765
+ #
1766
+ def tag_sha(tag_name)
1767
+ head = File.join(@git_dir, 'refs', 'tags', tag_name)
1768
+ return File.read(head).chomp if File.exist?(head)
1769
+
1770
+ result = Git::Commands::ShowRef::List.new(self).call(tag_name, tags: true, hash: true)
1771
+ result.stdout
1772
+ end
1773
+
1774
+ def repack
1775
+ Git::Commands::Repack.new(self).call(a: true, d: true)
1776
+ end
1777
+
1778
+ def gc
1779
+ Git::Commands::Gc.new(self).call(prune: true, aggressive: true, auto: true)
1780
+ end
1781
+
1782
+ # Execute git fsck to verify repository integrity
1783
+ #
1784
+ # @param objects [Array<String>] optional object identifiers to check
1785
+ # @param opts [Hash] command options (see {Git::Commands::Fsck#call})
1786
+ #
1787
+ # @return [Git::FsckResult] the structured result
1788
+ #
1789
+ # rubocop:disable Style/ArgumentsForwarding
1790
+ def fsck(*objects, **opts)
1791
+ result = Git::Commands::Fsck.new(self).call(*objects, no_progress: true, **opts)
1792
+ Git::Parsers::Fsck.parse(result.stdout)
1793
+ end
1794
+ # rubocop:enable Style/ArgumentsForwarding
1795
+
1796
+ READ_TREE_ALLOWED_OPTS = %i[prefix].freeze
1797
+
1798
+ def read_tree(treeish, opts = {})
1799
+ assert_valid_opts(opts, READ_TREE_ALLOWED_OPTS)
1800
+ allowed_opts = opts.slice(*READ_TREE_ALLOWED_OPTS)
1801
+ Git::Commands::ReadTree.new(self).call(treeish, **allowed_opts)
1802
+ end
1803
+
1804
+ def write_tree
1805
+ Git::Commands::WriteTree.new(self).call.stdout
1806
+ end
1807
+
1808
+ COMMIT_TREE_ALLOWED_OPTS = %i[p parent parents m message].freeze
1809
+
1810
+ def commit_tree(tree, opts = {})
1811
+ assert_valid_opts(opts, COMMIT_TREE_ALLOWED_OPTS)
1812
+ actual_opts = normalize_commit_tree_opts(opts, tree)
1813
+ Git::Commands::CommitTree.new(self).call(tree, **actual_opts).stdout
1814
+ end
1815
+
1816
+ def update_ref(ref, commit)
1817
+ Git::Commands::UpdateRef::Update.new(self).call(ref, commit)
1818
+ end
1819
+
1820
+ def checkout_index(opts = {})
1821
+ paths = normalize_pathspecs(opts[:path_limiter], 'path_limiter')
1822
+ keyword_opts = opts.except(:path_limiter)
1823
+ Git::Commands::CheckoutIndex.new(self).call(*paths.to_a, **keyword_opts)
1824
+ end
1825
+
1826
+ ARCHIVE_ALLOWED_OPTS = %i[prefix remote path format add_gzip].freeze
1827
+
1828
+ # Creates an archive of the given tree-ish and writes it to a file
1829
+ #
1830
+ # Delegates to {Git::Commands::Archive} for CLI execution. Format coercion
1831
+ # (`tgz` → `tar` + gzip), temp file management, and gzip post-processing
1832
+ # remain in this adapter.
1833
+ #
1834
+ # @see https://git-scm.com/docs/git-archive git-archive
1835
+ #
1836
+ # @param sha [String] tree-ish to archive (commit, tag, branch, or tree SHA)
1837
+ #
1838
+ # @param file [String, nil] destination file path; a unique temp file is
1839
+ # created and returned if `nil`
1840
+ #
1841
+ # @param opts [Hash] archive options
1842
+ #
1843
+ # @option opts [String] :prefix prefix to prepend to each filename in the archive
1844
+ #
1845
+ # @option opts [String] :remote URL of a remote repository to archive from
1846
+ #
1847
+ # @option opts [String] :path limit the archive to a path within the tree
1848
+ #
1849
+ # @option opts [String] :format archive format — `'tar'`, `'tgz'`, or `'zip'`
1850
+ # (default: `'zip'`)
1851
+ #
1852
+ # @option opts [Boolean, nil] :add_gzip (nil) wrap the archive in gzip compression
1853
+ #
1854
+ # @return [String] the path to the written archive file
1855
+ #
1856
+ # @raise [Git::FailedError] if `git archive` fails
1857
+ #
1858
+ def archive(sha, file = nil, opts = {})
1859
+ assert_valid_opts(opts, ARCHIVE_ALLOWED_OPTS)
1860
+ file ||= temp_file_name
1861
+ format, gzip = parse_archive_format_options(opts)
1862
+
1863
+ command_opts = opts.slice(:prefix, :remote).merge(format: format)
1864
+ path_args = opts[:path] ? [opts[:path]] : []
1865
+
1866
+ File.open(file, 'wb') do |f|
1867
+ Git::Commands::Archive.new(self).call(sha, *path_args, **command_opts, out: f)
1468
1868
  end
1869
+ apply_gzip(file) if gzip
1469
1870
 
1470
- command('fetch', *args, merge: true)
1871
+ file
1471
1872
  end
1472
1873
 
1473
- PUSH_OPTION_MAP = [
1474
- { keys: [:mirror], flag: '--mirror', type: :boolean },
1475
- { keys: [:delete], flag: '--delete', type: :boolean },
1476
- { keys: %i[force f], flag: '--force', type: :boolean },
1477
- { keys: [:push_option], flag: '--push-option', type: :repeatable_valued_space },
1478
- { keys: [:all], type: :validate_only }, # For validation purposes
1479
- { keys: [:tags], type: :validate_only } # From the `push` method's logic
1480
- ].freeze
1874
+ # Returns the git version as a Git::Version
1875
+ #
1876
+ # Parses the output of `git version`, strips platform suffixes (like
1877
+ # `.windows.1` or `.vfs.0`), and pads two-segment versions to three segments.
1878
+ #
1879
+ # Results are cached globally (keyed by binary path). It is assumed that the
1880
+ # git version doesn't change during runtime for a given binary.
1881
+ #
1882
+ # @return [Git::Version] the parsed git version
1883
+ #
1884
+ # @raise [Git::UnexpectedResultError] if the version string cannot be parsed
1885
+ #
1886
+ # @example
1887
+ # lib.git_version #=> Git::Version.new(2, 42, 1)
1888
+ #
1889
+ def git_version
1890
+ self.class.cached_git_version(Git::Base.config.binary_path) do
1891
+ output = Git::Commands::Version.new(self).call.stdout
1892
+ Git::Version.parse(output)
1893
+ end
1894
+ end
1481
1895
 
1482
- def push(remote = nil, branch = nil, opts = nil)
1483
- remote, branch, opts = normalize_push_args(remote, branch, opts)
1484
- ArgsBuilder.validate!(opts, PUSH_OPTION_MAP)
1896
+ # Class-level cache for git versions, keyed by binary path
1897
+ #
1898
+ # Thread-safe for JRuby/TruffleRuby where true parallelism exists.
1899
+ #
1900
+ # @api private
1901
+ #
1902
+ def self.cached_git_version(binary_path, &block)
1903
+ @git_version_cache_mutex.synchronize do
1904
+ @git_version_cache[binary_path] ||= block.call
1905
+ end
1906
+ end
1485
1907
 
1486
- raise ArgumentError, 'remote is required if branch is specified' if !remote && branch
1908
+ # Clear the git version cache (primarily for testing)
1909
+ #
1910
+ # @api private
1911
+ #
1912
+ def self.clear_git_version_cache
1913
+ @git_version_cache_mutex.synchronize do
1914
+ @git_version_cache.clear
1915
+ end
1916
+ end
1487
1917
 
1488
- args = build_push_args(remote, branch, opts)
1918
+ # Returns the current version of git, as an Array<Integer>
1919
+ #
1920
+ # @deprecated Use {Git.git_version} instead, which returns a {Git::Version} (not an Array).
1921
+ # For the legacy array shape, call: `Git.git_version.to_a`
1922
+ #
1923
+ def current_command_version
1924
+ Git::Deprecation.warn(
1925
+ 'Git::Lib#current_command_version is deprecated and will be removed in 6.0. ' \
1926
+ 'Use Git.git_version instead, which returns a Git::Version (not an Array). ' \
1927
+ 'For the legacy array shape, call: Git.git_version.to_a'
1928
+ )
1929
+ git_version.to_a
1930
+ end
1489
1931
 
1490
- if opts[:mirror]
1491
- command('push', *args)
1492
- else
1493
- command('push', *args)
1494
- command('push', '--tags', *(args - [branch].compact)) if opts[:tags]
1932
+ # Returns current_command_version <=> other_version
1933
+ #
1934
+ # @example
1935
+ # lib.compare_version_to(2, 41, 0) #=> 1
1936
+ # lib.compare_version_to(2, 42, 0) #=> 0
1937
+ # lib.compare_version_to(2, 43, 0) #=> -1
1938
+ #
1939
+ # @param other_version [Array<Integer>] the other version to compare to
1940
+ # @return [Integer] -1 if this version is less than other_version, 0 if equal, or 1 if greater than
1941
+ #
1942
+ # @deprecated Use {Git.git_version} with {Git::Version} comparison operators instead,
1943
+ # e.g. `Git.git_version <=> Git::Version.new(2, 41, 0)`
1944
+ #
1945
+ def compare_version_to(*other_version)
1946
+ Git::Deprecation.warn(
1947
+ 'Git::Lib#compare_version_to is deprecated and will be removed in 6.0. ' \
1948
+ 'Use Git.git_version with Git::Version comparison operators instead, ' \
1949
+ 'e.g. Git.git_version <=> Git::Version.new(2, 41, 0)'
1950
+ )
1951
+ git_version.to_a <=> other_version
1952
+ end
1953
+
1954
+ # @deprecated Use {Git::MINIMUM_GIT_VERSION} constant instead, which returns a {Git::Version}
1955
+ # (not an Array). For the legacy array shape, call: `Git::MINIMUM_GIT_VERSION.to_a.first(2)`
1956
+ #
1957
+ def required_command_version
1958
+ Git::Deprecation.warn(
1959
+ 'Git::Lib#required_command_version is deprecated and will be removed in 6.0. ' \
1960
+ 'Use the Git::MINIMUM_GIT_VERSION constant instead, which returns a Git::Version ' \
1961
+ '(not an Array). For the legacy array shape, call: Git::MINIMUM_GIT_VERSION.to_a.first(2)'
1962
+ )
1963
+ Git::MINIMUM_GIT_VERSION.to_a.first(2)
1964
+ end
1965
+
1966
+ # @deprecated For a boolean check, use `Git.git_version >= Git::MINIMUM_GIT_VERSION`.
1967
+ # For enforcement, no action is needed: {Git::Commands::Base#call} automatically
1968
+ # invokes `validate_version!`, which raises {Git::VersionError} on failure.
1969
+ #
1970
+ def meets_required_version?
1971
+ Git::Deprecation.warn(
1972
+ 'Git::Lib#meets_required_version? is deprecated and will be removed in 6.0. ' \
1973
+ 'For a boolean check, use: Git.git_version >= Git::MINIMUM_GIT_VERSION. ' \
1974
+ 'For enforcement, no action is needed: Git::Commands::Base#call automatically ' \
1975
+ 'invokes validate_version!, which raises Git::VersionError on failure.'
1976
+ )
1977
+ git_version >= Git::MINIMUM_GIT_VERSION
1978
+ end
1979
+
1980
+ # @deprecated Version validation is now handled automatically by
1981
+ # {Git::Commands::Base#validate_version!}, which raises {Git::VersionError} on failure.
1982
+ # Callers wanting the old warn-and-continue behavior must implement it themselves
1983
+ # using: `Git.git_version >= Git::MINIMUM_GIT_VERSION`.
1984
+ #
1985
+ def self.warn_if_old_command(_lib) # rubocop:disable Metrics/MethodLength, Naming/PredicateMethod
1986
+ Git::Deprecation.warn(
1987
+ 'Git::Lib.warn_if_old_command is deprecated and will be removed in 6.0. ' \
1988
+ 'Version validation is now handled automatically by Git::Commands::Base#validate_version!, ' \
1989
+ 'which RAISES Git::VersionError on failure (the old method only printed a warning ' \
1990
+ 'once per process and continued). Callers wanting the old warn-and-continue behavior ' \
1991
+ 'must implement it themselves using: Git.git_version >= Git::MINIMUM_GIT_VERSION.'
1992
+ )
1993
+
1994
+ return true if @version_checked
1995
+
1996
+ @version_checked = true
1997
+ git_version = Git.git_version
1998
+ unless git_version >= Git::MINIMUM_GIT_VERSION
1999
+ warn "The git gem requires git #{Git::MINIMUM_GIT_VERSION} or later, " \
2000
+ "but only found #{git_version}. You should probably upgrade."
1495
2001
  end
2002
+ true
1496
2003
  end
1497
2004
 
1498
- PULL_OPTION_MAP = [
1499
- { keys: [:allow_unrelated_histories], flag: '--allow-unrelated-histories', type: :boolean }
1500
- ].freeze
2005
+ COMMAND_CAPTURING_ARG_DEFAULTS = {
2006
+ in: nil,
2007
+ out: nil,
2008
+ err: nil,
2009
+ normalize: true,
2010
+ chomp: true,
2011
+ merge: false,
2012
+ chdir: nil,
2013
+ timeout: nil, # Don't set to Git.config.timeout here since it is mutable
2014
+ env: {},
2015
+ raise_on_failure: true
2016
+ }.freeze
1501
2017
 
1502
- def pull(remote = nil, branch = nil, opts = {})
1503
- raise ArgumentError, 'You must specify a remote if a branch is specified' if remote.nil? && !branch.nil?
2018
+ STATIC_GLOBAL_OPTS = %w[
2019
+ -c core.quotePath=true
2020
+ -c core.editor=false
2021
+ -c color.ui=false
2022
+ -c color.advice=false
2023
+ -c color.diff=false
2024
+ -c color.grep=false
2025
+ -c color.push=false
2026
+ -c color.remote=false
2027
+ -c color.showBranch=false
2028
+ -c color.status=false
2029
+ -c color.transport=false
2030
+ ].freeze
1504
2031
 
1505
- ArgsBuilder.validate!(opts, PULL_OPTION_MAP)
2032
+ # Runs a git command and returns the result
2033
+ #
2034
+ # By default, raises {Git::FailedError} if the command exits with a non-zero
2035
+ # status. Pass `raise_on_failure: false` to suppress this behavior.
2036
+ #
2037
+ # @overload command_capturing(*args, **options_hash)
2038
+ # Runs a git command and returns the result
2039
+ #
2040
+ # Args should exclude the 'git' command itself and global options.
2041
+ # Remember to splat the arguments if given as an array.
2042
+ #
2043
+ # @example Run git log
2044
+ # result = command_capturing('log', '--pretty=oneline')
2045
+ # result.stdout #=> "abc123 First commit\ndef456 Second commit\n"
2046
+ #
2047
+ # @example Using an array of arguments
2048
+ # args = ['log', '--pretty=oneline']
2049
+ # result = command_capturing(*args)
2050
+ #
2051
+ # @example Suppress raising on failure
2052
+ # result = command_capturing('show', 'nonexistent', raise_on_failure: false)
2053
+ # result.status.success? #=> false
2054
+ #
2055
+ # @param args [Array<String>] the command and its arguments
2056
+ #
2057
+ # @param options_hash [Hash] the options to pass to the command
2058
+ #
2059
+ # @option options_hash [IO, nil] :in the IO object to use as stdin for the command, or nil to
2060
+ # inherit the parent process stdin. Must be a real IO object with a file descriptor.
2061
+ #
2062
+ # @option options_hash [IO, String, #write, nil] :out the destination for captured stdout
2063
+ #
2064
+ # @option options_hash [IO, String, #write, nil] :err the destination for captured stderr
2065
+ #
2066
+ # @option options_hash [Boolean, nil] :normalize (true) normalize the output encoding to UTF-8
2067
+ #
2068
+ # @option options_hash [Boolean, nil] :chomp (true) remove trailing newlines from the output
2069
+ #
2070
+ # @option options_hash [Boolean, nil] :merge (false) merge stdout and stderr into a single output
2071
+ #
2072
+ # @option options_hash [String, nil] :chdir the directory to run the command in
2073
+ #
2074
+ # @option options_hash [Hash] :env additional environment variable overrides for this command
2075
+ #
2076
+ # @option options_hash [Boolean, nil] :raise_on_failure (true) whether to raise on non-zero exit
2077
+ #
2078
+ # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command to complete
2079
+ #
2080
+ # If timeout is nil, the global timeout from {Git::Config} is used.
2081
+ #
2082
+ # If timeout is zero, the timeout will not be enforced.
2083
+ #
2084
+ # If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised.
2085
+ #
2086
+ # If the command does not respond to SIGKILL, it will hang this method.
2087
+ #
2088
+ # @note Individual command classes (under {Git::Commands}) can selectively
2089
+ # expose `:timeout` and `:env` to their callers by declaring them as
2090
+ # execution options in their Arguments DSL definition and forwarding
2091
+ # them to this method. See {Git::Commands::Clone#call} for an example
2092
+ # of a command that exposes `:timeout`.
2093
+ #
2094
+ # @see Git::CommandLine::Capturing#run
2095
+ #
2096
+ # @see #command_line_capturing
2097
+ #
2098
+ # @return [Git::CommandLineResult] the result of the command
2099
+ #
2100
+ # @raise [ArgumentError] if an unknown option is passed
2101
+ #
2102
+ # @raise [Git::FailedError] if the command failed (when raise_on_failure is true)
2103
+ #
2104
+ # @raise [Git::SignaledError] if the command was signaled
2105
+ #
2106
+ # @raise [Git::TimeoutError] if the command times out
2107
+ #
2108
+ # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2109
+ #
2110
+ # The exception's `result` attribute is a {Git::CommandLineResult} which will
2111
+ # contain the result of the command including the exit status, stdout, and
2112
+ # stderr.
2113
+ #
2114
+ def command_capturing(*, **options_hash)
2115
+ options_hash = COMMAND_CAPTURING_ARG_DEFAULTS.merge(options_hash)
2116
+ options_hash[:timeout] ||= Git.config.timeout
1506
2117
 
1507
- flags = build_args(opts, PULL_OPTION_MAP)
1508
- positional_args = [remote, branch].compact
2118
+ extra_options = options_hash.keys - COMMAND_CAPTURING_ARG_DEFAULTS.keys
2119
+ raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
1509
2120
 
1510
- command('pull', *flags, *positional_args)
2121
+ env_overrides = options_hash.delete(:env)
2122
+ raise_on_failure = options_hash.delete(:raise_on_failure)
2123
+ command_line_capturing.run(*, raise_on_failure: raise_on_failure, env: env_overrides, **options_hash)
1511
2124
  end
1512
2125
 
1513
- def tag_sha(tag_name)
1514
- head = File.join(@git_dir, 'refs', 'tags', tag_name)
1515
- return File.read(head).chomp if File.exist?(head)
2126
+ COMMAND_STREAMING_ARG_DEFAULTS = {
2127
+ in: nil,
2128
+ out: nil,
2129
+ err: nil,
2130
+ chdir: nil,
2131
+ timeout: nil,
2132
+ env: {},
2133
+ raise_on_failure: true
2134
+ }.freeze
2135
+
2136
+ # Runs a git command using the streaming (non-capturing) execution path
2137
+ #
2138
+ # Unlike {#command_capturing}, stdout is NOT buffered in memory. It is
2139
+ # written only to the IO object provided via the `out:` option. Stderr is
2140
+ # captured internally via a StringIO for error diagnostics.
2141
+ #
2142
+ # Use this entry point when you want to stream large output (e.g. blob
2143
+ # content from cat-file) without creating memory pressure.
2144
+ #
2145
+ # @overload command_streaming(*args, **options_hash)
2146
+ #
2147
+ # @param args [Array<String>] the git command and its arguments
2148
+ #
2149
+ # @param options_hash [Hash] the options to pass to the command
2150
+ #
2151
+ # @option options_hash [IO, nil] :in stdin IO object
2152
+ #
2153
+ # @option options_hash [#write, nil] :out destination for streamed stdout
2154
+ #
2155
+ # @option options_hash [#write, nil] :err an optional additional destination to receive stderr output
2156
+ # in real time. Stderr is always captured internally; when `err:` is supplied, writes are teed
2157
+ # to both the internal buffer and this destination. `result.stderr` always reflects the internal capture.
2158
+ #
2159
+ # @option options_hash [String, nil] :chdir the directory to run the command in
2160
+ #
2161
+ # @option options_hash [Hash] :env additional environment variable overrides for this command
2162
+ #
2163
+ # @option options_hash [Boolean, nil] :raise_on_failure (true) whether to raise on non-zero exit
2164
+ #
2165
+ # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command
2166
+ #
2167
+ # If nil, the global timeout from {Git::Config} is used.
2168
+ #
2169
+ # @return [Git::CommandLineResult] the result of the command
2170
+ #
2171
+ # `result.stdout` will always be `''` — stdout was streamed to `out:`.
2172
+ # `result.stderr` contains any stderr output captured for diagnostics.
2173
+ #
2174
+ # @raise [ArgumentError] if an unknown option is passed
2175
+ #
2176
+ # @raise [Git::FailedError] if the command failed (when raise_on_failure is true)
2177
+ #
2178
+ # @raise [Git::SignaledError] if the command was signaled
2179
+ #
2180
+ # @raise [Git::TimeoutError] if the command times out
2181
+ #
2182
+ # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2183
+ #
2184
+ # @see Git::CommandLine::Streaming#run
2185
+ #
2186
+ # @see #command_line_streaming
2187
+ #
2188
+ def command_streaming(*, **options_hash)
2189
+ options_hash = COMMAND_STREAMING_ARG_DEFAULTS.merge(options_hash)
2190
+ options_hash[:timeout] ||= Git.config.timeout
1516
2191
 
1517
- begin
1518
- command('show-ref', '--tags', '-s', tag_name)
1519
- rescue Git::FailedError => e
1520
- raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
2192
+ extra_options = options_hash.keys - COMMAND_STREAMING_ARG_DEFAULTS.keys
2193
+ raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
1521
2194
 
1522
- ''
1523
- end
2195
+ env_overrides = options_hash.delete(:env)
2196
+ raise_on_failure = options_hash.delete(:raise_on_failure)
2197
+ command_line_streaming.run(*, raise_on_failure: raise_on_failure, env: env_overrides, **options_hash)
1524
2198
  end
1525
2199
 
1526
- def repack
1527
- command('repack', '-a', '-d')
2200
+ private
2201
+
2202
+ def migrate_clean_legacy_options(opts)
2203
+ opts = deprecate_clean_option(opts, :ff, ':ff option is deprecated. Use force: 2 instead.')
2204
+ deprecate_clean_option(opts, :force_force, ':force_force option is deprecated. Use force: 2 instead.')
1528
2205
  end
1529
2206
 
1530
- def gc
1531
- command('gc', '--prune', '--aggressive', '--auto')
1532
- end
1533
-
1534
- FSCK_OPTION_MAP = [
1535
- { flag: '--no-progress', type: :static },
1536
- { keys: [:unreachable], flag: '--unreachable', type: :boolean },
1537
- { keys: [:strict], flag: '--strict', type: :boolean },
1538
- { keys: [:connectivity_only], flag: '--connectivity-only', type: :boolean },
1539
- { keys: [:root], flag: '--root', type: :boolean },
1540
- { keys: [:tags], flag: '--tags', type: :boolean },
1541
- { keys: [:cache], flag: '--cache', type: :boolean },
1542
- { keys: [:no_reflogs], flag: '--no-reflogs', type: :boolean },
1543
- { keys: [:lost_found], flag: '--lost-found', type: :boolean },
1544
- { keys: [:dangling], flag: '--dangling', type: :boolean_negatable },
1545
- { keys: [:full], flag: '--full', type: :boolean_negatable },
1546
- { keys: [:name_objects], flag: '--name-objects', type: :boolean_negatable },
1547
- { keys: [:references], flag: '--references', type: :boolean_negatable }
1548
- ].freeze
2207
+ def deprecate_clean_option(opts, key, message)
2208
+ return opts unless opts.key?(key)
1549
2209
 
1550
- def fsck(*objects, **opts)
1551
- args = ArgsBuilder.build(opts, FSCK_OPTION_MAP)
1552
- args.concat(objects) unless objects.empty?
1553
- # fsck returns non-zero exit status when issues are found:
1554
- # 1 = errors found, 2 = missing objects, 4 = warnings
1555
- # We still want to parse the output in these cases
1556
- output = begin
1557
- command('fsck', *args)
1558
- rescue Git::FailedError => e
1559
- raise unless [1, 2, 4].include?(e.result.status.exitstatus)
1560
-
1561
- e.result.stdout
1562
- end
1563
- parse_fsck_output(output)
1564
- end
2210
+ opts = opts.dup
2211
+ deprecated_value = opts.delete(key)
2212
+ validate_deprecated_clean_option_value!(key, deprecated_value)
1565
2213
 
1566
- READ_TREE_OPTION_MAP = [
1567
- { keys: [:prefix], flag: '--prefix', type: :valued_equals }
1568
- ].freeze
2214
+ Git::Deprecation.warn(message)
2215
+ return opts unless deprecated_value
1569
2216
 
1570
- def read_tree(treeish, opts = {})
1571
- ArgsBuilder.validate!(opts, READ_TREE_OPTION_MAP)
1572
- flags = build_args(opts, READ_TREE_OPTION_MAP)
1573
- command('read-tree', *flags, treeish)
2217
+ opts[:force] = merge_clean_force_option(opts[:force], force_specified: force_option_specified?(opts))
2218
+ opts
1574
2219
  end
1575
2220
 
1576
- def write_tree
1577
- command('write-tree')
2221
+ def force_option_specified?(opts)
2222
+ opts.key?(:force) && !opts[:force].nil?
1578
2223
  end
1579
2224
 
1580
- COMMIT_TREE_OPTION_MAP = [
1581
- { keys: %i[parent parents], flag: '-p', type: :repeatable_valued_space },
1582
- { keys: [:message], flag: '-m', type: :valued_space }
1583
- ].freeze
1584
-
1585
- def commit_tree(tree, opts = {})
1586
- opts[:message] ||= "commit tree #{tree}"
1587
- ArgsBuilder.validate!(opts, COMMIT_TREE_OPTION_MAP)
1588
-
1589
- flags = build_args(opts, COMMIT_TREE_OPTION_MAP)
1590
- command('commit-tree', tree, *flags)
1591
- end
2225
+ def validate_deprecated_clean_option_value!(key, value)
2226
+ return if value.nil? || value == true || value == false
1592
2227
 
1593
- def update_ref(ref, commit)
1594
- command('update-ref', ref, commit)
2228
+ raise ArgumentError, "#{key} option only accepts true, false, or nil"
1595
2229
  end
1596
2230
 
1597
- CHECKOUT_INDEX_OPTION_MAP = [
1598
- { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1599
- { keys: [:force], flag: '--force', type: :boolean },
1600
- { keys: [:all], flag: '--all', type: :boolean },
1601
- { keys: [:path_limiter], type: :validate_only }
1602
- ].freeze
2231
+ def merge_clean_force_option(existing_force, force_specified: false)
2232
+ return 2 unless force_specified
1603
2233
 
1604
- def checkout_index(opts = {})
1605
- ArgsBuilder.validate!(opts, CHECKOUT_INDEX_OPTION_MAP)
1606
- args = build_args(opts, CHECKOUT_INDEX_OPTION_MAP)
2234
+ normalized_force = normalize_clean_force_option(existing_force)
1607
2235
 
1608
- if (path = opts[:path_limiter]) && path.is_a?(String)
1609
- args.push('--', path)
2236
+ case normalized_force
2237
+ when Integer then merge_integer_clean_force_option(normalized_force)
2238
+ when false
2239
+ 2
2240
+ else
2241
+ normalized_force
1610
2242
  end
1611
-
1612
- command('checkout-index', *args)
1613
2243
  end
1614
2244
 
1615
- ARCHIVE_OPTION_MAP = [
1616
- { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1617
- { keys: [:remote], flag: '--remote', type: :valued_equals },
1618
- # These options are used by helpers or handled manually
1619
- { keys: [:path], type: :validate_only },
1620
- { keys: [:format], type: :validate_only },
1621
- { keys: [:add_gzip], type: :validate_only }
1622
- ].freeze
1623
-
1624
- def archive(sha, file = nil, opts = {})
1625
- ArgsBuilder.validate!(opts, ARCHIVE_OPTION_MAP)
1626
- file ||= temp_file_name
1627
- format, gzip = parse_archive_format_options(opts)
1628
-
1629
- args = build_args(opts, ARCHIVE_OPTION_MAP)
1630
- args.unshift("--format=#{format}")
1631
- args << sha
1632
- args.push('--', opts[:path]) if opts[:path]
1633
-
1634
- File.open(file, 'wb') { |f| command('archive', *args, out: f) }
1635
- apply_gzip(file) if gzip
2245
+ def merge_integer_clean_force_option(normalized_force)
2246
+ return normalized_force if normalized_force < 1
1636
2247
 
1637
- file
2248
+ [normalized_force, 2].max
1638
2249
  end
1639
2250
 
1640
- # returns the current version of git, as an Array of Fixnums.
1641
- def current_command_version
1642
- output = command('version')
1643
- version = output[/\d+(\.\d+)+/]
1644
- version_parts = version.split('.').collect(&:to_i)
1645
- version_parts.fill(0, version_parts.length...3)
2251
+ def normalize_clean_force_option(value)
2252
+ case value
2253
+ when true then 1
2254
+ else value
2255
+ end
1646
2256
  end
1647
2257
 
1648
- # Returns current_command_version <=> other_version
2258
+ # Build a result hash from clone options for Git::Base.new
1649
2259
  #
1650
- # @example
1651
- # lib.current_command_version #=> [2, 42, 0]
2260
+ # Parses the clone directory from the git command's stderr output, which
2261
+ # contains either:
2262
+ # Cloning into '<directory>'...
2263
+ # Cloning into bare repository '<directory>'...
1652
2264
  #
1653
- # lib.compare_version_to(2, 41, 0) #=> 1
1654
- # lib.compare_version_to(2, 42, 0) #=> 0
1655
- # lib.compare_version_to(2, 43, 0) #=> -1
2265
+ # @param command_line_result [Git::CommandLineResult] the result of the git clone command
1656
2266
  #
1657
- # @param other_version [Array<Object>] the other version to compare to
1658
- # @return [Integer] -1 if this version is less than other_version, 0 if equal, or 1 if greater than
2267
+ # @param opts [Hash] execution context options (:log, :git_ssh)
1659
2268
  #
1660
- def compare_version_to(*other_version)
1661
- current_command_version <=> other_version
2269
+ # @return [Hash] result hash with directory, log, and git_ssh keys
2270
+ #
2271
+ def build_clone_result(command_line_result, opts)
2272
+ clone_dir, bare = parse_clone_stderr(command_line_result.stderr)
2273
+ result = bare ? { repository: clone_dir } : { working_directory: clone_dir }
2274
+ result[:log] = opts[:log] if opts[:log]
2275
+ result[:git_ssh] = opts[:git_ssh] if opts.key?(:git_ssh)
2276
+ result
1662
2277
  end
1663
2278
 
1664
- def required_command_version
1665
- [2, 28]
1666
- end
2279
+ # Parse the clone directory and bare status from git clone's stderr output
2280
+ #
2281
+ # Git outputs the directory in an unencoded way (no `core.quotePath` or
2282
+ # similar escaping applies to clone's stderr message). The message format
2283
+ # is always:
2284
+ #
2285
+ # Cloning into '<directory>'...
2286
+ # Cloning into bare repository '<directory>'...
2287
+ #
2288
+ # Because the directory name is not escaped, a name containing the
2289
+ # literal sequence `'...` (single-quote followed by three dots) would
2290
+ # be ambiguous. In practice this is extremely unlikely.
2291
+ #
2292
+ # @param stderr [String] stderr output from git clone
2293
+ #
2294
+ # @return [Array(String, Boolean)] the clone directory and whether it's a bare repository
2295
+ #
2296
+ # @raise [Git::UnexpectedResultError] if the stderr output cannot be parsed
2297
+ #
2298
+ def parse_clone_stderr(stderr)
2299
+ match = stderr.match(/Cloning into (?:(bare repository) )?'(.+)'\.\.\./)
2300
+ raise Git::UnexpectedResultError, "Unable to determine clone directory from: #{stderr}" unless match
1667
2301
 
1668
- def meets_required_version?
1669
- (current_command_version <=> required_command_version) >= 0
2302
+ [match[2], !match[1].nil?]
1670
2303
  end
1671
2304
 
1672
- def self.warn_if_old_command(lib) # rubocop:disable Naming/PredicateMethod
1673
- Git::Deprecation.warn('Git::Lib#warn_if_old_command is deprecated. Use meets_required_version?.')
1674
-
1675
- return true if @version_checked
2305
+ # Prefixes clone result path values with the chdir directory.
2306
+ #
2307
+ # Mutates the given result hash in place, updating any :working_directory
2308
+ # and :repository entries to be rooted under the provided +chdir+ directory.
2309
+ # If +chdir+ is nil, the hash is left unchanged.
2310
+ #
2311
+ # @param result [Hash] clone result hash containing path information
2312
+ # @param chdir [String, nil] directory under which the repository was cloned
2313
+ # @return [nil]
2314
+ #
2315
+ def prefix_clone_result_paths!(result, chdir)
2316
+ return unless chdir
1676
2317
 
1677
- @version_checked = true
1678
- unless lib.meets_required_version?
1679
- warn "[WARNING] The git gem requires git #{lib.required_command_version.join('.')} or later, " \
1680
- "but only found #{lib.current_command_version.join('.')}. You should probably upgrade."
2318
+ %i[working_directory repository].each do |key|
2319
+ result[key] = File.join(chdir, result[key]) if result.key?(key)
1681
2320
  end
1682
- true
1683
2321
  end
1684
2322
 
1685
- COMMAND_ARG_DEFAULTS = {
1686
- out: nil,
1687
- err: nil,
1688
- normalize: true,
1689
- chomp: true,
1690
- merge: false,
1691
- chdir: nil,
1692
- timeout: nil # Don't set to Git.config.timeout here since it is mutable
1693
- }.freeze
1694
-
1695
- STATIC_GLOBAL_OPTS = %w[
1696
- -c core.quotePath=true
1697
- -c color.ui=false
1698
- -c color.advice=false
1699
- -c color.diff=false
1700
- -c color.grep=false
1701
- -c color.push=false
1702
- -c color.remote=false
1703
- -c color.showBranch=false
1704
- -c color.status=false
1705
- -c color.transport=false
1706
- ].freeze
2323
+ # Handles the deprecated :path option for Git::Lib#clone.
2324
+ #
2325
+ # If opts contains :path, emits a deprecation warning and migrates the
2326
+ # value to :chdir (unless :chdir is already set). Mutates opts in place.
2327
+ #
2328
+ # @param opts [Hash] clone options, possibly containing :path
2329
+ # @return [nil]
2330
+ #
2331
+ def deprecate_clone_path_option!(opts)
2332
+ return unless opts.key?(:path)
1707
2333
 
1708
- LOG_OPTION_MAP = [
1709
- { type: :static, flag: '--no-color' },
1710
- { keys: [:all], flag: '--all', type: :boolean },
1711
- { keys: [:cherry], flag: '--cherry', type: :boolean },
1712
- { keys: [:since], flag: '--since', type: :valued_equals },
1713
- { keys: [:until], flag: '--until', type: :valued_equals },
1714
- { keys: [:grep], flag: '--grep', type: :valued_equals },
1715
- { keys: [:author], flag: '--author', type: :valued_equals },
1716
- { keys: [:count], flag: '--max-count', type: :valued_equals },
1717
- { keys: [:between], type: :custom, builder: ->(value) { "#{value[0]}..#{value[1]}" if value } }
1718
- ].freeze
2334
+ Git::Deprecation.warn('The :path option for Git::Lib#clone is deprecated, use :chdir instead')
2335
+ path = opts.delete(:path)
2336
+ opts[:chdir] ||= path
2337
+ end
1719
2338
 
1720
- FSCK_OBJECT_PATTERN = /\A(dangling|missing|unreachable) (\w+) ([0-9a-f]{40})(?: \((.+)\))?\z/
1721
- FSCK_WARNING_PATTERN = /\Awarning in (\w+) ([0-9a-f]{40}): (.+)\z/
1722
- FSCK_ROOT_PATTERN = /\Aroot ([0-9a-f]{40})\z/
1723
- FSCK_TAGGED_PATTERN = /\Atagged (\w+) ([0-9a-f]{40}) \((.+)\) in ([0-9a-f]{40})\z/
2339
+ def deprecate_clone_recursive_option!(opts)
2340
+ return unless opts.key?(:recursive)
1724
2341
 
1725
- private_constant :FSCK_OBJECT_PATTERN, :FSCK_WARNING_PATTERN, :FSCK_ROOT_PATTERN, :FSCK_TAGGED_PATTERN
2342
+ Git::Deprecation.warn('The :recursive option for Git::Lib#clone is deprecated, use :recurse_submodules instead')
2343
+ opts[:recurse_submodules] = opts.delete(:recursive)
2344
+ end
1726
2345
 
1727
- private
2346
+ def deprecate_clone_remote_option!(opts)
2347
+ return unless opts.key?(:remote)
1728
2348
 
1729
- def parse_fsck_output(output)
1730
- result = { dangling: [], missing: [], unreachable: [], warnings: [], root: [], tagged: [] }
1731
- output.each_line { |line| parse_fsck_line(line.strip, result) }
1732
- Git::FsckResult.new(**result)
2349
+ Git::Deprecation.warn('The :remote option for Git::Lib#clone is deprecated, use :origin instead')
2350
+ opts[:origin] = opts.delete(:remote)
1733
2351
  end
1734
2352
 
1735
- def parse_fsck_line(line, result)
1736
- parse_fsck_object_line(line, result) ||
1737
- parse_fsck_warning_line(line, result) ||
1738
- parse_fsck_root_line(line, result) ||
1739
- parse_fsck_tagged_line(line, result)
2353
+ def deprecate_clone_options!(opts)
2354
+ deprecate_clone_path_option!(opts)
2355
+ deprecate_clone_recursive_option!(opts)
2356
+ deprecate_clone_remote_option!(opts)
1740
2357
  end
1741
2358
 
1742
- def parse_fsck_object_line(line, result)
1743
- return unless (match = FSCK_OBJECT_PATTERN.match(line))
2359
+ def deprecate_commit_add_all_option!(opts)
2360
+ return unless opts.key?(:add_all)
1744
2361
 
1745
- result[match[1].to_sym] << Git::FsckObject.new(type: match[2].to_sym, sha: match[3], name: match[4])
2362
+ Git::Deprecation.warn('The :add_all option for Git::Lib#commit is deprecated, use :all instead')
2363
+ opts[:all] = opts.delete(:add_all)
1746
2364
  end
1747
2365
 
1748
- def parse_fsck_warning_line(line, result)
1749
- return unless (match = FSCK_WARNING_PATTERN.match(line))
1750
-
1751
- result[:warnings] << Git::FsckObject.new(type: match[1].to_sym, sha: match[2], message: match[3])
2366
+ # Extracts execution context options from clone options.
2367
+ #
2368
+ # @param opts [Hash] clone options
2369
+ # @return [Hash] hash with :log and :git_ssh keys if present
2370
+ #
2371
+ def extract_clone_execution_context_opts(opts)
2372
+ result = {}
2373
+ result[:log] = opts.delete(:log) if opts[:log]
2374
+ result[:git_ssh] = opts.delete(:git_ssh) if opts.key?(:git_ssh)
2375
+ result
1752
2376
  end
1753
2377
 
1754
- def parse_fsck_root_line(line, result)
1755
- return unless (match = FSCK_ROOT_PATTERN.match(line))
2378
+ # Translate legacy merge option names to new interface
2379
+ #
2380
+ # @param opts [Hash] options with possibly legacy keys
2381
+ # @return [Hash] options with new keys
2382
+ #
2383
+ def translate_merge_options(opts)
2384
+ result = opts.dup
2385
+
2386
+ # :message => 'msg' becomes :m => 'msg' (git merge uses -m, not --message)
2387
+ result[:m] = result.delete(:message) if result.key?(:message)
1756
2388
 
1757
- result[:root] << Git::FsckObject.new(type: :commit, sha: match[1])
2389
+ result
1758
2390
  end
1759
2391
 
1760
- def parse_fsck_tagged_line(line, result)
1761
- return unless (match = FSCK_TAGGED_PATTERN.match(line))
2392
+ # Extract name-status data from --raw output lines
2393
+ #
2394
+ # Raw lines have the format:
2395
+ # :old_mode new_mode old_sha new_sha status\tpath
2396
+ # or for renames/copies:
2397
+ # :old_mode new_mode old_sha new_sha Rxx\told_path\tnew_path
2398
+ #
2399
+ # @param output [String] raw diff output
2400
+ #
2401
+ # @return [Hash] mapping of file paths to status tokens
2402
+ #
2403
+ def extract_name_status_from_raw(output)
2404
+ output.split("\n").each_with_object({}) do |line, memo|
2405
+ next unless line.start_with?(':')
1762
2406
 
1763
- result[:tagged] << Git::FsckObject.new(type: match[1].to_sym, sha: match[2], name: match[3])
2407
+ parts = line[1..].split(/\s+/, 5)
2408
+ status_and_paths = parts[4].split("\t")
2409
+ status = status_and_paths[0]
2410
+ path = status_and_paths.length > 2 ? status_and_paths[2] : status_and_paths[1]
2411
+ memo[unescape_quoted_path(path)] = status
2412
+ end
1764
2413
  end
1765
2414
 
1766
- def parse_diff_path_status(args)
1767
- command_lines('diff', *args).each_with_object({}) do |line, memo|
1768
- status, path = split_status_line(line)
1769
- memo[path] = status
1770
- end
2415
+ # Extract only the patch text from combined numstat + shortstat + patch output
2416
+ #
2417
+ # When {Git::Commands::Diff} is called with `patch: true, numstat: true, shortstat: true`,
2418
+ # the output contains numstat, shortstat, and patch sections. This method extracts
2419
+ # only the patch portion (starting at "diff --git").
2420
+ #
2421
+ # @param output [String] combined command output
2422
+ #
2423
+ # @return [String] only the patch text
2424
+ #
2425
+ def extract_patch_text(output)
2426
+ match = output.match(/^diff --git /m)
2427
+ match ? output[match.begin(0)..] : output
1771
2428
  end
1772
2429
 
1773
- def build_checkout_positional_args(branch, opts)
1774
- args = []
1775
- if opts[:new_branch] || opts[:b]
1776
- args.push('-b', branch)
1777
- args << opts[:start_point] if opts[:start_point]
1778
- elsif branch
1779
- args << branch
1780
- end
1781
- args
2430
+ # Extract only the numstat lines from combined numstat + shortstat output
2431
+ #
2432
+ # When {Git::Commands::Diff} is called with `numstat: true, shortstat: true`,
2433
+ # the output contains numstat lines followed by a shortstat summary line. This method
2434
+ # filters out the shortstat line and empty lines, returning only the numstat lines.
2435
+ #
2436
+ # @param output [String] combined command output
2437
+ #
2438
+ # @return [Array<String>] only the numstat lines
2439
+ #
2440
+ def extract_numstat_lines(output)
2441
+ output.split("\n").reject { |l| l.empty? || l.match?(/^\s*\d+\s+files?\s+changed/) }
1782
2442
  end
1783
2443
 
1784
2444
  def build_args(opts, option_map)
1785
2445
  Git::ArgsBuilder.new(opts, option_map).build
1786
2446
  end
1787
2447
 
2448
+ def validate_tag_options!(opts)
2449
+ needs_message = %i[a annotate s sign u local_user].any? { |k| opts[k] }
2450
+ has_message = opts[:m] || opts[:message]
2451
+
2452
+ return unless needs_message && !has_message
2453
+
2454
+ raise ArgumentError, 'Cannot create an annotated or signed tag without a message.'
2455
+ end
2456
+
2457
+ def delete_tag(name)
2458
+ result = Git::Commands::Tag::Delete.new(self).call(name)
2459
+ raise Git::FailedError, result if result.status.exitstatus.positive?
2460
+
2461
+ result.stdout
2462
+ end
2463
+
2464
+ def create_tag(name, target, opts)
2465
+ Git::Commands::Tag::Create.new(self).call(name, target, **opts).stdout
2466
+ end
2467
+
1788
2468
  def initialize_from_base(base_object)
1789
2469
  @git_dir = base_object.repo.to_s
1790
2470
  @git_index_file = base_object.index&.to_s
@@ -1799,15 +2479,6 @@ module Git
1799
2479
  @git_ssh = base_hash.key?(:git_ssh) ? base_hash[:git_ssh] : :use_global_config
1800
2480
  end
1801
2481
 
1802
- def return_base_opts_from_clone(clone_dir, opts)
1803
- base_opts = {}
1804
- base_opts[:repository] = clone_dir if opts[:bare] || opts[:mirror]
1805
- base_opts[:working_directory] = clone_dir unless opts[:bare] || opts[:mirror]
1806
- base_opts[:log] = opts[:log] if opts[:log]
1807
- base_opts[:git_ssh] = opts[:git_ssh] if opts.key?(:git_ssh)
1808
- base_opts
1809
- end
1810
-
1811
2482
  def process_commit_headers(data)
1812
2483
  headers = { 'parent' => [] } # Pre-initialize for multiple parents
1813
2484
  each_cat_file_header(data) do |key, value|
@@ -1820,44 +2491,8 @@ module Git
1820
2491
  headers
1821
2492
  end
1822
2493
 
1823
- def parse_branch_line(line, index, all_lines)
1824
- match_data = match_branch_line(line, index, all_lines)
1825
-
1826
- return nil if match_data[:not_a_branch] || match_data[:detached_ref]
1827
-
1828
- format_branch_data(match_data)
1829
- end
1830
-
1831
- def match_branch_line(line, index, all_lines)
1832
- match_data = line.match(BRANCH_LINE_REGEXP)
1833
- raise Git::UnexpectedResultError, unexpected_branch_line_error(all_lines, line, index) unless match_data
1834
-
1835
- match_data
1836
- end
1837
-
1838
- def format_branch_data(match_data)
1839
- [
1840
- match_data[:refname],
1841
- !match_data[:current].nil?,
1842
- !match_data[:worktree].nil?,
1843
- match_data[:symref]
1844
- ]
1845
- end
1846
-
1847
- def unexpected_branch_line_error(lines, line, index)
1848
- <<~ERROR
1849
- Unexpected line in output from `git branch -a`, line #{index + 1}
1850
-
1851
- Full output:
1852
- #{lines.join("\n ")}
1853
-
1854
- Line #{index + 1}:
1855
- "#{line}"
1856
- ERROR
1857
- end
1858
-
1859
2494
  def get_branch_state(branch_name)
1860
- command('rev-parse', '--verify', '--quiet', branch_name)
2495
+ Git::Commands::RevParse.new(self).call(branch_name, verify: true, quiet: true)
1861
2496
  :active
1862
2497
  rescue Git::FailedError => e
1863
2498
  # An exit status of 1 with empty stderr from `rev-parse --verify`
@@ -1867,23 +2502,14 @@ module Git
1867
2502
  :unborn
1868
2503
  end
1869
2504
 
1870
- def execute_grep_command(args)
1871
- command_lines('grep', *args)
1872
- rescue Git::FailedError => e
1873
- # `git grep` returns 1 when no lines are selected.
1874
- raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
1875
-
1876
- [] # Return an empty array for "no matches found"
2505
+ def normalize_grep_opts(opts)
2506
+ opts = opts.dup
2507
+ opts[:pathspec] = opts.delete(:path_limiter) if opts.key?(:path_limiter)
2508
+ opts
1877
2509
  end
1878
2510
 
1879
- def parse_grep_output(lines)
1880
- lines.each_with_object(Hash.new { |h, k| h[k] = [] }) do |line, hsh|
1881
- match = line.match(/\A(.*?):(\d+):(.*)/)
1882
- next unless match
1883
-
1884
- _full, filename, line_num, text = match.to_a
1885
- hsh[filename] << [line_num.to_i, text]
1886
- end
2511
+ def parse_grep_output(output)
2512
+ Git::Parsers::Grep.parse(output)
1887
2513
  end
1888
2514
 
1889
2515
  def parse_diff_stats_output(lines)
@@ -1908,6 +2534,18 @@ module Git
1908
2534
  parts
1909
2535
  end
1910
2536
 
2537
+ def parse_raw_diff_output(stdout)
2538
+ stdout.split("\n").each_with_object({}) do |line, memo|
2539
+ info, file = split_status_line(line)
2540
+ mode_src, mode_dest, sha_src, sha_dest, type = info.split
2541
+ memo[file] = {
2542
+ mode_index: mode_dest, mode_repo: mode_src.to_s[1, 7],
2543
+ path: file, sha_repo: sha_src, sha_index: sha_dest,
2544
+ type: type
2545
+ }
2546
+ end
2547
+ end
2548
+
1911
2549
  def build_final_stats_hash(file_stats)
1912
2550
  {
1913
2551
  total: build_total_stats(file_stats),
@@ -1953,45 +2591,48 @@ module Git
1953
2591
  [type, name, value]
1954
2592
  end
1955
2593
 
1956
- def stash_log_lines
1957
- path = File.join(@git_dir, 'logs/refs/stash')
1958
- return [] unless File.exist?(path)
1959
-
1960
- File.readlines(path, chomp: true)
1961
- end
1962
-
1963
- def parse_stash_log_line(line, index)
1964
- full_message = line.split("\t", 2).last
2594
+ # Convert a StashInfo to the legacy [index, message] format
2595
+ #
2596
+ # The legacy format strips the "WIP on <branch>:" or "On <branch>:" prefix
2597
+ # from the message and returns only the suffix.
2598
+ #
2599
+ # @param info [Git::StashInfo] the stash info object
2600
+ # @return [Array(Integer, String)] `[index, message]` pair with prefix stripped
2601
+ #
2602
+ # @api private
2603
+ #
2604
+ def stash_info_to_legacy(info, index = info.index)
2605
+ full_message = info.message
1965
2606
  match_data = full_message.match(/^[^:]+:(.*)$/)
1966
2607
  message = match_data ? match_data[1] : full_message
1967
2608
 
1968
2609
  [index, message.strip]
1969
2610
  end
1970
2611
 
1971
- # Writes the staged content of a conflicted file to an IO stream
2612
+ # Streams the staged content of a file at a given index stage to an IO object
2613
+ #
2614
+ # Uses the streaming execution path so content is written directly to `out_io`
2615
+ # without being buffered in memory.
2616
+ #
2617
+ # @api private
1972
2618
  #
1973
2619
  # @param path [String] the path to the file in the index
1974
2620
  #
1975
- # @param stage [Integer] the stage of the file to show (e.g., 2 for 'ours', 3 for 'theirs')
2621
+ # @param stage [Integer] the index stage to read (e.g., `1` ancestor, `2` ours, `3` theirs)
1976
2622
  #
1977
- # @param out_io [IO] the IO object to write the staged content to
2623
+ # @param out_io [IO] the `IO` object to stream the staged content into
1978
2624
  #
1979
- # @return [IO] the IO object that was written to
2625
+ # @return [IO] `out_io`, as passed in
2626
+ #
2627
+ # @raise [Git::FailedError] if the object does not exist or git exits non-zero
2628
+ #
2629
+ # @raise [Git::TimeoutError] if the command exceeds the configured timeout
1980
2630
  #
1981
2631
  def write_staged_content(path, stage, out_io)
1982
- command('show', ":#{stage}:#{path}", out: out_io)
2632
+ Git::Commands::Show.new(self).call(":#{stage}:#{path}", out: out_io)
1983
2633
  out_io
1984
2634
  end
1985
2635
 
1986
- def validate_tag_options!(opts)
1987
- is_annotated = opts[:a] || opts[:annotate]
1988
- has_message = opts[:m] || opts[:message]
1989
-
1990
- return unless is_annotated && !has_message
1991
-
1992
- raise ArgumentError, 'Cannot create an annotated tag without a message.'
1993
- end
1994
-
1995
2636
  def normalize_push_args(remote, branch, opts)
1996
2637
  if branch.is_a?(Hash)
1997
2638
  opts = branch
@@ -2007,15 +2648,22 @@ module Git
2007
2648
  [remote, branch, opts]
2008
2649
  end
2009
2650
 
2010
- def build_push_args(remote, branch, opts)
2011
- # Build the simple flags using the ArgsBuilder
2012
- args = build_args(opts, PUSH_OPTION_MAP)
2651
+ def validate_push_args!(remote, branch, opts)
2652
+ assert_valid_opts(opts, PUSH_ALLOWED_OPTS)
2653
+ raise ArgumentError, 'remote is required if branch is specified' if !remote && branch
2654
+ end
2655
+
2656
+ def push_refs(remote, branch, opts)
2657
+ positionals = [remote, branch].compact
2658
+ Git::Commands::Push.new(self).call(*positionals, **opts.except(:tags))
2659
+ end
2660
+
2661
+ def push_tags_separately?(opts)
2662
+ opts[:tags] && !opts[:mirror]
2663
+ end
2013
2664
 
2014
- # Manually handle the flag with external dependencies and positional args
2015
- args << '--all' if opts[:all] && remote
2016
- args << remote if remote
2017
- args << branch if branch
2018
- args
2665
+ def push_tags(remote, opts)
2666
+ Git::Commands::Push.new(self).call(*[remote].compact, **opts)
2019
2667
  end
2020
2668
 
2021
2669
  def temp_file_name
@@ -2037,16 +2685,6 @@ module Git
2037
2685
  Zlib::GzipWriter.open(file) { |gz| gz.write(file_content) }
2038
2686
  end
2039
2687
 
2040
- def command_lines(cmd, *opts, chdir: nil)
2041
- cmd_op = command(cmd, *opts, chdir: chdir)
2042
- op = if cmd_op.encoding.name == 'UTF-8'
2043
- cmd_op
2044
- else
2045
- cmd_op.encode('UTF-8', 'binary', invalid: :replace, undef: :replace)
2046
- end
2047
- op.split("\n")
2048
- end
2049
-
2050
2688
  # Returns a hash of environment variable overrides for git commands
2051
2689
  #
2052
2690
  # This method builds a hash of environment variables that control git's behavior,
@@ -2074,7 +2712,7 @@ module Git
2074
2712
  # @example Unsetting an environment variable (used by worktree_command_line)
2075
2713
  # env_overrides('GIT_INDEX_FILE' => nil)
2076
2714
  # # => { 'GIT_DIR' => '/path/to/.git', 'GIT_WORK_TREE' => '/path/to/worktree',
2077
- # # 'GIT_INDEX_FILE' => nil, 'GIT_SSH' => <git_ssh_value>, 'LC_ALL' => <pinned_locale> }
2715
+ # # 'GIT_INDEX_FILE' => nil, 'GIT_SSH' => <git_ssh_value>, 'LC_ALL' => 'en_US.UTF-8' }
2078
2716
  # # When passed to Process.spawn, GIT_INDEX_FILE will be unset in the environment
2079
2717
  #
2080
2718
  # @see https://ruby-doc.org/core/Process.html#method-c-spawn Process.spawn
@@ -2087,67 +2725,11 @@ module Git
2087
2725
  'GIT_WORK_TREE' => @git_work_dir,
2088
2726
  'GIT_INDEX_FILE' => @git_index_file,
2089
2727
  'GIT_SSH' => resolved_git_ssh,
2090
- # Pin the locale so git's behavior does not depend on the user's environment.
2091
- # Added for issue #753, where a German user's `git branch` output
2092
- # ("* (HEAD losgelöst bei origin/25.1)") broke branch parsing.
2093
- #
2094
- # The pin has two halves, and both are load-bearing here:
2095
- #
2096
- # - Messages: keeps git's human-readable text in English. `BRANCH_LINE_REGEXP`
2097
- # matches the literal English strings "(HEAD detached at ...)" and
2098
- # "(not a branch)" in `git branch -a` output, which is the exact issue #753
2099
- # failure mode.
2100
- # - Ctype: makes git's regex engine match *characters* rather than *bytes*.
2101
- # Under a C ctype, `grep` and `log --grep` silently return wrong answers —
2102
- # with exit status zero — for any pattern whose metacharacters span
2103
- # non-ASCII text.
2104
- #
2105
- # So do not "simplify" this to `C`: that yields English messages and a broken
2106
- # ctype, which is the worst of both. A pinned locale the host does not have
2107
- # degrades to that same C ctype, which is why each branch below has to name a
2108
- # locale that actually exists on the platform it applies to:
2109
- #
2110
- # - Non-Darwin: `C.UTF-8`. Stock Debian, Ubuntu, and RHEL images generate no
2111
- # `en_US.UTF-8`, so pinning it there produced exactly the silent breakage
2112
- # above. (musl ignores the locale name for ctype, so Alpine is UTF-8 either
2113
- # way.)
2114
- # - Darwin: `en_US.UTF-8`, which every macOS release ships. `C.UTF-8` did not
2115
- # arrive until macOS 15, so macOS 11–14 — including Intel Macs that are
2116
- # hardware-capped below 15 — would break under it. Delete this branch once
2117
- # macOS 14 and earlier are out of support.
2118
- #
2119
- # Windows takes the non-Darwin branch, where the value makes no difference:
2120
- # Git for Windows folds case and runs PCRE in UTF mode under every value, and
2121
- # matches bytes for `.` and POSIX classes under every value — measured on git
2122
- # 2.55.0 against `en_US.UTF-8`, `C.UTF-8`, `C`, and no pin at all.
2123
- #
2124
- # RHEL 7 (glibc 2.17) has neither locale and gets a C ctype whatever is pinned.
2125
- # It is EOL, and there is deliberately no public override for this value.
2126
- 'LC_ALL' => darwin_platform? ? 'en_US.UTF-8' : 'C.UTF-8'
2728
+ 'GIT_EDITOR' => 'true', # Use a no-op editor so Git skips interactive editing but continues
2729
+ 'LC_ALL' => 'en_US.UTF-8'
2127
2730
  }.merge(additional_overrides)
2128
2731
  end
2129
2732
 
2130
- # Whether this process is running on macOS
2131
- #
2132
- # Checks `RUBY_DESCRIPTION` as well as `RUBY_PLATFORM` because JRuby reports
2133
- # `RUBY_PLATFORM` as `"java"` on every host and records the real platform only in
2134
- # `RUBY_DESCRIPTION` (as, for example, `"... [arm64-darwin]"`). Testing
2135
- # `RUBY_PLATFORM` alone would put JRuby on macOS onto the non-Darwin branch, which is
2136
- # the one platform pairing that branch must not be given. This is the same detection
2137
- # the test suite uses for Windows, and for the same reason.
2138
- #
2139
- # Test the platform plainly rather than sniffing the Darwin version: `RUBY_PLATFORM`
2140
- # records the version Ruby was *built* against, so a Ruby built on macOS 14 still
2141
- # reports `darwin23` when run on macOS 15.
2142
- #
2143
- # @return [Boolean] true if this process is running on macOS
2144
- #
2145
- # @api private
2146
- #
2147
- def darwin_platform?
2148
- RUBY_PLATFORM.include?('darwin') || RUBY_DESCRIPTION.include?('darwin')
2149
- end
2150
-
2151
2733
  # Resolve the git_ssh value to use for this instance
2152
2734
  #
2153
2735
  # @return [String, nil] the resolved git_ssh value
@@ -2171,154 +2753,85 @@ module Git
2171
2753
  end
2172
2754
  end
2173
2755
 
2174
- def command_line
2175
- @command_line ||=
2176
- Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2177
- end
2178
-
2179
- # Returns a command line instance without GIT_INDEX_FILE for worktree commands
2180
- #
2181
- # Git worktrees manage their own index files and setting GIT_INDEX_FILE
2182
- # causes corruption of both the main worktree and new worktree indexes.
2183
- #
2184
- # @return [Git::CommandLine]
2185
- # @api private
2186
- #
2187
- def worktree_command_line
2188
- @worktree_command_line ||=
2189
- Git::CommandLine.new(env_overrides('GIT_INDEX_FILE' => nil), Git::Base.config.binary_path, global_opts,
2190
- @logger)
2191
- end
2192
-
2193
- # @overload worktree_command(*args, **options_hash)
2194
- # Runs a git worktree command and returns the output
2756
+ # Returns the {Git::CommandLine::Capturing} instance used for capturing execution
2195
2757
  #
2196
- # This method is similar to #command but uses a command line instance
2197
- # that excludes GIT_INDEX_FILE from the environment to prevent index corruption.
2758
+ # Memoized factory for the capturing execution path. Instantiates
2759
+ # {Git::CommandLine::Capturing} with the current environment, binary path,
2760
+ # global options, and logger.
2198
2761
  #
2199
- # @param args [Array<String>] the command arguments
2200
- # @param options_hash [Hash] the options to pass to the command
2201
- #
2202
- # @return [String] the command's stdout
2762
+ # @return [Git::CommandLine::Capturing]
2203
2763
  #
2204
- # @see #command
2205
- #
2206
- # @api private
2764
+ # @see Git::CommandLine::Capturing#run
2207
2765
  #
2208
- def worktree_command(*, **options_hash)
2209
- options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2210
- options_hash[:timeout] ||= Git.config.timeout
2211
-
2212
- extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2213
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2214
-
2215
- result = worktree_command_line.run(*, **options_hash)
2216
- result.stdout
2766
+ def command_line_capturing
2767
+ @command_line_capturing ||=
2768
+ Git::CommandLine::Capturing.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2217
2769
  end
2218
2770
 
2219
- # Runs a git command and returns the output
2220
- #
2221
- # Additional args are passed to the command line. They should exclude the 'git'
2222
- # command itself and global options. Remember to splat the the arguments if given
2223
- # as an array.
2224
- #
2225
- # For example, to run `git log --pretty=oneline`, you would create the array
2226
- # `args = ['log', '--pretty=oneline']` and call `command(*args)`.
2227
- #
2228
- # @param options_hash [Hash] the options to pass to the command
2229
- # @option options_hash [IO, String, #write, nil] :out the destination for captured stdout
2230
- # @option options_hash [IO, String, #write, nil] :err the destination for captured stderr
2231
- # @option options_hash [Boolean] :normalize true to normalize the output encoding to UTF-8
2232
- # @option options_hash [Boolean] :chomp true to remove trailing newlines from the output
2233
- # @option options_hash [Boolean] :merge true to merge stdout and stderr into a single output
2234
- # @option options_hash [String, nil] :chdir the directory to run the command in
2235
- # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command to complete
2236
- #
2237
- # If timeout is nil, the global timeout from {Git::Config} is used.
2238
- #
2239
- # If timeout is zero, the timeout will not be enforced.
2240
- #
2241
- # If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised.
2242
- #
2243
- # If the command does not respond to SIGKILL, it will hang this method.
2244
- #
2245
- # @see Git::CommandLine#run
2246
- #
2247
- # @return [String] the command's stdout (or merged stdout and stderr if `merge`
2248
- # is true)
2249
- #
2250
- # @raise [ArgumentError] if an unknown option is passed
2771
+ # Returns the {Git::CommandLine::Streaming} instance used for streaming execution
2251
2772
  #
2252
- # @raise [Git::FailedError] if the command failed
2773
+ # Memoized factory for the streaming execution path. Instantiates
2774
+ # {Git::CommandLine::Streaming} with the current environment, binary path,
2775
+ # global options, and logger.
2253
2776
  #
2254
- # @raise [Git::SignaledError] if the command was signaled
2255
- #
2256
- # @raise [Git::TimeoutError] if the command times out
2257
- #
2258
- # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2259
- #
2260
- # The exception's `result` attribute is a {Git::CommandLineResult} which will
2261
- # contain the result of the command including the exit status, stdout, and
2262
- # stderr.
2777
+ # @return [Git::CommandLine::Streaming]
2263
2778
  #
2264
- # @api private
2779
+ # @see Git::CommandLine::Streaming#run
2265
2780
  #
2266
- def command(*, **options_hash)
2267
- options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2268
- options_hash[:timeout] ||= Git.config.timeout
2269
-
2270
- extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2271
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2272
-
2273
- result = command_line.run(*, **options_hash)
2274
- result.stdout
2781
+ def command_line_streaming
2782
+ @command_line_streaming ||=
2783
+ Git::CommandLine::Streaming.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2275
2784
  end
2276
2785
 
2277
- # Takes the diff command line output (as Array) and parse it into a Hash
2786
+ # Validates the :count option for log commands.
2278
2787
  #
2279
- # @param [String] diff_command the diff commadn to be used
2280
- # @param [Array] opts the diff options to be used
2281
- # @return [Hash] the diff as Hash
2282
- def diff_as_hash(diff_command, opts = [])
2283
- # update index before diffing to avoid spurious diffs
2284
- command('status')
2285
- command_lines(diff_command, *opts).each_with_object({}) do |line, memo|
2286
- info, file = split_status_line(line)
2287
- mode_src, mode_dest, sha_src, sha_dest, type = info.split
2788
+ def validate_log_count_option!(opts)
2789
+ return unless opts[:count] && !opts[:count].is_a?(Integer)
2288
2790
 
2289
- memo[file] = {
2290
- mode_index: mode_dest, mode_repo: mode_src.to_s[1, 7],
2291
- path: file, sha_repo: sha_src, sha_index: sha_dest,
2292
- type: type
2293
- }
2294
- end
2791
+ raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
2295
2792
  end
2296
2793
 
2297
- # Returns an array holding the common options for the log commands
2794
+ # Builds the positional revision range argument(s) from opts for Git::Commands::Log
2298
2795
  #
2299
- # @param [Hash] opts the given options
2300
- # @return [Array] the set of common options that the log command will use
2301
- def log_common_options(opts)
2302
- if opts[:count] && !opts[:count].is_a?(Integer)
2303
- raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
2796
+ # @param opts [Hash]
2797
+ # @return [Array<String>] zero or one element array with the revision range expression
2798
+ def log_revision_range_args(opts)
2799
+ if opts[:between]
2800
+ ["#{opts[:between][0]}..#{opts[:between][1]}"]
2801
+ elsif opts[:object].is_a?(String)
2802
+ [opts[:object]]
2803
+ else
2804
+ []
2304
2805
  end
2305
-
2306
- build_args(opts, LOG_OPTION_MAP)
2307
2806
  end
2308
2807
 
2309
- # Retrurns an array holding path options for the log commands
2808
+ # Builds the common keyword options for Git::Commands::Log from opts
2310
2809
  #
2311
- # @param [Hash] opts the given options
2312
- # @return [Array] the set of path options that the log command will use
2313
- def log_path_options(opts)
2314
- arr_opts = []
2315
-
2316
- arr_opts << opts[:object] if opts[:object].is_a? String
2317
- if opts[:path_limiter]
2318
- arr_opts << '--'
2319
- arr_opts += Array(opts[:path_limiter])
2810
+ # @param opts [Hash]
2811
+ # @param extra [Hash] additional options to merge in (caller-specific)
2812
+ # @return [Hash] keyword arguments for Git::Commands::Log#call
2813
+ def log_base_call_options(opts, extra = {})
2814
+ {
2815
+ all: opts[:all],
2816
+ cherry: opts[:cherry],
2817
+ since: opts[:since],
2818
+ until: opts[:until],
2819
+ grep: opts[:grep],
2820
+ author: opts[:author],
2821
+ max_count: opts[:count],
2822
+ path: opts[:path_limiter] ? Array(opts[:path_limiter]) : nil
2823
+ }.merge(extra).compact
2824
+ end
2825
+
2826
+ def run_log_command(revision_range_args, call_opts)
2827
+ log_or_empty_on_unborn do
2828
+ result = Git::Commands::Log.new(self).call(
2829
+ *revision_range_args,
2830
+ no_color: true, pretty: 'raw',
2831
+ **call_opts
2832
+ )
2833
+ process_commit_log_data(result.stdout.split("\n"))
2320
2834
  end
2321
- arr_opts
2322
2835
  end
2323
2836
 
2324
2837
  def log_or_empty_on_unborn
@@ -2329,5 +2842,14 @@ module Git
2329
2842
 
2330
2843
  []
2331
2844
  end
2845
+
2846
+ def normalize_commit_tree_opts(opts, tree)
2847
+ opts.dup.tap do |actual_opts|
2848
+ actual_opts[:p] = actual_opts.delete(:parents) if actual_opts.key?(:parents)
2849
+ actual_opts[:p] = actual_opts.delete(:parent) if actual_opts.key?(:parent)
2850
+ actual_opts[:m] = actual_opts.delete(:message) if actual_opts.key?(:message)
2851
+ actual_opts[:m] = "commit tree #{tree}" if actual_opts[:m].nil?
2852
+ end
2853
+ end
2332
2854
  end
2333
2855
  end