git 4.3.2 → 5.0.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/.github/copilot-instructions.md +67 -2705
  3. data/.github/pull_request_template.md +3 -1
  4. data/.github/skills/breaking-change-analysis/SKILL.md +102 -0
  5. data/.github/skills/ci-cd-troubleshooting/SKILL.md +264 -0
  6. data/.github/skills/command-implementation/REFERENCE.md +993 -0
  7. data/.github/skills/command-implementation/SKILL.md +229 -0
  8. data/.github/skills/command-test-conventions/SKILL.md +660 -0
  9. data/.github/skills/command-yard-documentation/SKILL.md +426 -0
  10. data/.github/skills/dependency-management/SKILL.md +72 -0
  11. data/.github/skills/development-workflow/SKILL.md +506 -0
  12. data/.github/skills/extract-command-from-lib/SKILL.md +487 -0
  13. data/.github/skills/extract-facade-from-base-lib/SKILL.md +586 -0
  14. data/.github/skills/facade-implementation/REFERENCE.md +840 -0
  15. data/.github/skills/facade-implementation/SKILL.md +260 -0
  16. data/.github/skills/facade-test-conventions/SKILL.md +380 -0
  17. data/.github/skills/facade-yard-documentation/SKILL.md +429 -0
  18. data/.github/skills/make-skill-template/SKILL.md +176 -0
  19. data/.github/skills/pr-readiness-review/SKILL.md +185 -0
  20. data/.github/skills/project-context/SKILL.md +313 -0
  21. data/.github/skills/pull-request-review/SKILL.md +168 -0
  22. data/.github/skills/refactor-command-to-commandlineresult/SKILL.md +131 -0
  23. data/.github/skills/release-management/SKILL.md +125 -0
  24. data/.github/skills/review-arguments-dsl/CHECKLIST.md +788 -0
  25. data/.github/skills/review-arguments-dsl/SKILL.md +214 -0
  26. data/.github/skills/review-backward-compatibility/SKILL.md +275 -0
  27. data/.github/skills/review-cross-command-consistency/SKILL.md +139 -0
  28. data/.github/skills/reviewing-skills/SKILL.md +189 -0
  29. data/.github/skills/rspec-unit-testing-standards/SKILL.md +639 -0
  30. data/.github/skills/tdd-refactor-step/SKILL.md +236 -0
  31. data/.github/skills/test-debugging/SKILL.md +160 -0
  32. data/.github/skills/yard-documentation/SKILL.md +793 -0
  33. data/.github/workflows/continuous_integration.yml +3 -2
  34. data/.github/workflows/enforce_conventional_commits.yml +1 -1
  35. data/.github/workflows/experimental_continuous_integration.yml +2 -2
  36. data/.github/workflows/release.yml +3 -4
  37. data/.gitignore +8 -0
  38. data/.husky/pre-commit +13 -0
  39. data/.release-please-manifest.json +1 -1
  40. data/.rspec +3 -0
  41. data/.rubocop.yml +7 -3
  42. data/.rubocop_todo.yml +23 -5
  43. data/.yardopts +1 -0
  44. data/CHANGELOG.md +0 -40
  45. data/CONTRIBUTING.md +694 -53
  46. data/README.md +17 -5
  47. data/Rakefile +61 -9
  48. data/commitlint.test +4 -0
  49. data/git.gemspec +14 -8
  50. data/lib/git/args_builder.rb +0 -8
  51. data/lib/git/base.rb +486 -410
  52. data/lib/git/branch.rb +380 -43
  53. data/lib/git/branch_delete_failure.rb +31 -0
  54. data/lib/git/branch_delete_result.rb +63 -0
  55. data/lib/git/branch_info.rb +178 -0
  56. data/lib/git/branches.rb +130 -24
  57. data/lib/git/command_line/base.rb +245 -0
  58. data/lib/git/command_line/capturing.rb +249 -0
  59. data/lib/git/command_line/result.rb +96 -0
  60. data/lib/git/command_line/streaming.rb +194 -0
  61. data/lib/git/command_line.rb +43 -322
  62. data/lib/git/command_line_result.rb +4 -88
  63. data/lib/git/commands/add.rb +131 -0
  64. data/lib/git/commands/am/abort.rb +43 -0
  65. data/lib/git/commands/am/apply.rb +252 -0
  66. data/lib/git/commands/am/continue.rb +43 -0
  67. data/lib/git/commands/am/quit.rb +43 -0
  68. data/lib/git/commands/am/retry.rb +47 -0
  69. data/lib/git/commands/am/show_current_patch.rb +64 -0
  70. data/lib/git/commands/am/skip.rb +42 -0
  71. data/lib/git/commands/am.rb +33 -0
  72. data/lib/git/commands/apply.rb +237 -0
  73. data/lib/git/commands/archive/list_formats.rb +46 -0
  74. data/lib/git/commands/archive.rb +140 -0
  75. data/lib/git/commands/arguments.rb +3510 -0
  76. data/lib/git/commands/base.rb +403 -0
  77. data/lib/git/commands/branch/copy.rb +94 -0
  78. data/lib/git/commands/branch/create.rb +173 -0
  79. data/lib/git/commands/branch/delete.rb +80 -0
  80. data/lib/git/commands/branch/list.rb +162 -0
  81. data/lib/git/commands/branch/move.rb +94 -0
  82. data/lib/git/commands/branch/set_upstream.rb +86 -0
  83. data/lib/git/commands/branch/show_current.rb +49 -0
  84. data/lib/git/commands/branch/unset_upstream.rb +57 -0
  85. data/lib/git/commands/branch.rb +34 -0
  86. data/lib/git/commands/cat_file/batch.rb +364 -0
  87. data/lib/git/commands/cat_file/filtered.rb +105 -0
  88. data/lib/git/commands/cat_file/raw.rb +210 -0
  89. data/lib/git/commands/cat_file.rb +49 -0
  90. data/lib/git/commands/checkout/branch.rb +151 -0
  91. data/lib/git/commands/checkout/files.rb +115 -0
  92. data/lib/git/commands/checkout.rb +38 -0
  93. data/lib/git/commands/checkout_index.rb +105 -0
  94. data/lib/git/commands/clean.rb +100 -0
  95. data/lib/git/commands/clone.rb +240 -0
  96. data/lib/git/commands/commit.rb +272 -0
  97. data/lib/git/commands/commit_tree.rb +100 -0
  98. data/lib/git/commands/config_option_syntax/add.rb +83 -0
  99. data/lib/git/commands/config_option_syntax/get.rb +117 -0
  100. data/lib/git/commands/config_option_syntax/get_all.rb +115 -0
  101. data/lib/git/commands/config_option_syntax/get_color.rb +91 -0
  102. data/lib/git/commands/config_option_syntax/get_color_bool.rb +93 -0
  103. data/lib/git/commands/config_option_syntax/get_regexp.rb +115 -0
  104. data/lib/git/commands/config_option_syntax/get_urlmatch.rb +102 -0
  105. data/lib/git/commands/config_option_syntax/list.rb +107 -0
  106. data/lib/git/commands/config_option_syntax/remove_section.rb +74 -0
  107. data/lib/git/commands/config_option_syntax/rename_section.rb +78 -0
  108. data/lib/git/commands/config_option_syntax/replace_all.rb +104 -0
  109. data/lib/git/commands/config_option_syntax/set.rb +114 -0
  110. data/lib/git/commands/config_option_syntax/unset.rb +89 -0
  111. data/lib/git/commands/config_option_syntax/unset_all.rb +89 -0
  112. data/lib/git/commands/config_option_syntax.rb +56 -0
  113. data/lib/git/commands/describe.rb +155 -0
  114. data/lib/git/commands/diff.rb +656 -0
  115. data/lib/git/commands/diff_files.rb +518 -0
  116. data/lib/git/commands/diff_index.rb +496 -0
  117. data/lib/git/commands/fetch.rb +352 -0
  118. data/lib/git/commands/fsck.rb +136 -0
  119. data/lib/git/commands/gc.rb +132 -0
  120. data/lib/git/commands/grep.rb +338 -0
  121. data/lib/git/commands/init.rb +99 -0
  122. data/lib/git/commands/log.rb +632 -0
  123. data/lib/git/commands/ls_files.rb +191 -0
  124. data/lib/git/commands/ls_remote.rb +155 -0
  125. data/lib/git/commands/ls_tree.rb +131 -0
  126. data/lib/git/commands/maintenance/register.rb +75 -0
  127. data/lib/git/commands/maintenance/run.rb +104 -0
  128. data/lib/git/commands/maintenance/start.rb +66 -0
  129. data/lib/git/commands/maintenance/stop.rb +55 -0
  130. data/lib/git/commands/maintenance/unregister.rb +79 -0
  131. data/lib/git/commands/maintenance.rb +31 -0
  132. data/lib/git/commands/merge/abort.rb +44 -0
  133. data/lib/git/commands/merge/continue.rb +44 -0
  134. data/lib/git/commands/merge/quit.rb +46 -0
  135. data/lib/git/commands/merge/start.rb +245 -0
  136. data/lib/git/commands/merge.rb +28 -0
  137. data/lib/git/commands/merge_base.rb +86 -0
  138. data/lib/git/commands/mv.rb +77 -0
  139. data/lib/git/commands/name_rev.rb +114 -0
  140. data/lib/git/commands/pull.rb +377 -0
  141. data/lib/git/commands/push.rb +246 -0
  142. data/lib/git/commands/read_tree.rb +149 -0
  143. data/lib/git/commands/remote/add.rb +91 -0
  144. data/lib/git/commands/remote/get_url.rb +66 -0
  145. data/lib/git/commands/remote/list.rb +54 -0
  146. data/lib/git/commands/remote/prune.rb +61 -0
  147. data/lib/git/commands/remote/remove.rb +52 -0
  148. data/lib/git/commands/remote/rename.rb +69 -0
  149. data/lib/git/commands/remote/set_branches.rb +63 -0
  150. data/lib/git/commands/remote/set_head.rb +82 -0
  151. data/lib/git/commands/remote/set_url.rb +71 -0
  152. data/lib/git/commands/remote/set_url_add.rb +61 -0
  153. data/lib/git/commands/remote/set_url_delete.rb +64 -0
  154. data/lib/git/commands/remote/show.rb +71 -0
  155. data/lib/git/commands/remote/update.rb +72 -0
  156. data/lib/git/commands/remote.rb +42 -0
  157. data/lib/git/commands/repack.rb +277 -0
  158. data/lib/git/commands/reset.rb +147 -0
  159. data/lib/git/commands/rev_parse.rb +297 -0
  160. data/lib/git/commands/revert/abort.rb +45 -0
  161. data/lib/git/commands/revert/continue.rb +57 -0
  162. data/lib/git/commands/revert/quit.rb +47 -0
  163. data/lib/git/commands/revert/skip.rb +44 -0
  164. data/lib/git/commands/revert/start.rb +153 -0
  165. data/lib/git/commands/revert.rb +29 -0
  166. data/lib/git/commands/rm.rb +114 -0
  167. data/lib/git/commands/show.rb +632 -0
  168. data/lib/git/commands/show_ref/exclude_existing.rb +120 -0
  169. data/lib/git/commands/show_ref/exists.rb +78 -0
  170. data/lib/git/commands/show_ref/list.rb +145 -0
  171. data/lib/git/commands/show_ref/verify.rb +120 -0
  172. data/lib/git/commands/show_ref.rb +42 -0
  173. data/lib/git/commands/stash/apply.rb +75 -0
  174. data/lib/git/commands/stash/branch.rb +65 -0
  175. data/lib/git/commands/stash/clear.rb +41 -0
  176. data/lib/git/commands/stash/create.rb +58 -0
  177. data/lib/git/commands/stash/drop.rb +67 -0
  178. data/lib/git/commands/stash/list.rb +39 -0
  179. data/lib/git/commands/stash/pop.rb +78 -0
  180. data/lib/git/commands/stash/push.rb +103 -0
  181. data/lib/git/commands/stash/show.rb +149 -0
  182. data/lib/git/commands/stash/store.rb +63 -0
  183. data/lib/git/commands/stash.rb +38 -0
  184. data/lib/git/commands/status.rb +169 -0
  185. data/lib/git/commands/symbolic_ref/delete.rb +68 -0
  186. data/lib/git/commands/symbolic_ref/read.rb +95 -0
  187. data/lib/git/commands/symbolic_ref/update.rb +76 -0
  188. data/lib/git/commands/symbolic_ref.rb +38 -0
  189. data/lib/git/commands/tag/create.rb +139 -0
  190. data/lib/git/commands/tag/delete.rb +55 -0
  191. data/lib/git/commands/tag/list.rb +143 -0
  192. data/lib/git/commands/tag/verify.rb +71 -0
  193. data/lib/git/commands/tag.rb +26 -0
  194. data/lib/git/commands/update_ref/batch.rb +140 -0
  195. data/lib/git/commands/update_ref/delete.rb +92 -0
  196. data/lib/git/commands/update_ref/update.rb +106 -0
  197. data/lib/git/commands/update_ref.rb +42 -0
  198. data/lib/git/commands/version.rb +52 -0
  199. data/lib/git/commands/worktree/add.rb +140 -0
  200. data/lib/git/commands/worktree/list.rb +64 -0
  201. data/lib/git/commands/worktree/lock.rb +58 -0
  202. data/lib/git/commands/worktree/management_base.rb +51 -0
  203. data/lib/git/commands/worktree/move.rb +66 -0
  204. data/lib/git/commands/worktree/prune.rb +67 -0
  205. data/lib/git/commands/worktree/remove.rb +63 -0
  206. data/lib/git/commands/worktree/repair.rb +76 -0
  207. data/lib/git/commands/worktree/unlock.rb +47 -0
  208. data/lib/git/commands/worktree.rb +43 -0
  209. data/lib/git/commands/write_tree.rb +68 -0
  210. data/lib/git/commands.rb +89 -0
  211. data/lib/git/detached_head_info.rb +54 -0
  212. data/lib/git/diff.rb +297 -7
  213. data/lib/git/diff_file_numstat_info.rb +29 -0
  214. data/lib/git/diff_file_patch_info.rb +134 -0
  215. data/lib/git/diff_file_raw_info.rb +127 -0
  216. data/lib/git/diff_info.rb +169 -0
  217. data/lib/git/diff_path_status.rb +78 -19
  218. data/lib/git/diff_result.rb +32 -0
  219. data/lib/git/diff_stats.rb +59 -14
  220. data/lib/git/dirstat_info.rb +86 -0
  221. data/lib/git/errors.rb +65 -2
  222. data/lib/git/execution_context/global.rb +56 -0
  223. data/lib/git/execution_context/repository.rb +147 -0
  224. data/lib/git/execution_context.rb +482 -0
  225. data/lib/git/file_ref.rb +74 -0
  226. data/lib/git/fsck_object.rb +9 -9
  227. data/lib/git/fsck_result.rb +1 -1
  228. data/lib/git/lib.rb +1606 -1028
  229. data/lib/git/log.rb +15 -2
  230. data/lib/git/object.rb +92 -22
  231. data/lib/git/parsers/branch.rb +224 -0
  232. data/lib/git/parsers/cat_file.rb +111 -0
  233. data/lib/git/parsers/diff.rb +585 -0
  234. data/lib/git/parsers/fsck.rb +133 -0
  235. data/lib/git/parsers/grep.rb +42 -0
  236. data/lib/git/parsers/ls_tree.rb +58 -0
  237. data/lib/git/parsers/stash.rb +208 -0
  238. data/lib/git/parsers/tag.rb +257 -0
  239. data/lib/git/remote.rb +133 -9
  240. data/lib/git/repository/branching.rb +572 -0
  241. data/lib/git/repository/committing.rb +191 -0
  242. data/lib/git/repository/configuring.rb +156 -0
  243. data/lib/git/repository/diffing.rb +775 -0
  244. data/lib/git/repository/inspecting.rb +153 -0
  245. data/lib/git/repository/logging.rb +247 -0
  246. data/lib/git/repository/merging.rb +295 -0
  247. data/lib/git/repository/object_operations.rb +1101 -0
  248. data/lib/git/repository/path_resolver.rb +207 -0
  249. data/lib/git/repository/remote_operations.rb +753 -0
  250. data/lib/git/repository/shared_private.rb +51 -0
  251. data/lib/git/repository/staging.rb +390 -0
  252. data/lib/git/repository/stashing.rb +107 -0
  253. data/lib/git/repository/status_operations.rb +180 -0
  254. data/lib/git/repository/worktree_operations.rb +159 -0
  255. data/lib/git/repository.rb +264 -1
  256. data/lib/git/stash.rb +85 -4
  257. data/lib/git/stash_info.rb +104 -0
  258. data/lib/git/stashes.rb +130 -13
  259. data/lib/git/status.rb +224 -18
  260. data/lib/git/tag_delete_failure.rb +31 -0
  261. data/lib/git/tag_delete_result.rb +63 -0
  262. data/lib/git/tag_info.rb +105 -0
  263. data/lib/git/version.rb +109 -2
  264. data/lib/git/version_constraint.rb +81 -0
  265. data/lib/git/worktree.rb +120 -5
  266. data/lib/git/worktrees.rb +107 -7
  267. data/lib/git.rb +114 -18
  268. data/redesign/1_architecture_existing.md +54 -18
  269. data/redesign/2_architecture_redesign.md +365 -46
  270. data/redesign/3_architecture_implementation.md +1451 -54
  271. data/tasks/gem_tasks.rake +4 -0
  272. data/tasks/npm_tasks.rake +7 -0
  273. data/tasks/rspec.rake +48 -0
  274. data/tasks/test.rake +13 -1
  275. data/tasks/yard.rake +34 -7
  276. metadata +349 -20
  277. data/lib/git/index.rb +0 -6
  278. data/lib/git/path.rb +0 -38
  279. data/lib/git/working_directory.rb +0 -6
  280. /data/{release-please-config.json → .release-please-config.json} +0 -0
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'
@@ -11,12 +18,15 @@ require 'process_executer'
11
18
  require 'stringio'
12
19
  require 'tempfile'
13
20
  require 'zlib'
14
- require 'open3'
15
21
 
16
22
  module Git
17
23
  # Internal git operations
18
24
  # @api private
19
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
+
20
30
  # The path to the Git working copy. The default is '"./.git"'.
21
31
  #
22
32
  # @return [Pathname] the path to the Git working copy.
@@ -75,52 +85,29 @@ module Git
75
85
  end
76
86
  end
77
87
 
78
- INIT_OPTION_MAP = [
79
- { keys: [:bare], flag: '--bare', type: :boolean },
80
- { keys: [:initial_branch], flag: '--initial-branch', type: :valued_equals }
81
- ].freeze
82
-
83
- # 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`)
84
101
  #
85
- # options:
86
- # :bare
87
- # :working_directory
88
- # :initial_branch
102
+ # @option opts [String, nil] :repository (nil) deprecated — use `:separate_git_dir` instead
103
+ #
104
+ # @return [String] the command output
89
105
  #
90
106
  def init(opts = {})
91
- args = build_args(opts, INIT_OPTION_MAP)
92
- command('init', *args)
93
- end
94
-
95
- CLONE_OPTION_MAP = [
96
- { keys: [:bare], flag: '--bare', type: :boolean },
97
- { keys: [:recursive], flag: '--recursive', type: :boolean },
98
- { keys: [:mirror], flag: '--mirror', type: :boolean },
99
- { keys: [:branch], flag: '--branch', type: :valued_space },
100
- { keys: [:filter], flag: '--filter', type: :valued_space },
101
- { keys: %i[remote origin], flag: '--origin', type: :valued_space },
102
- { keys: [:config], flag: '--config', type: :repeatable_valued_space },
103
- {
104
- keys: [:single_branch],
105
- type: :custom,
106
- validator: ->(value) { [nil, true, false].include?(value) },
107
- builder: lambda do |value|
108
- case value
109
- when true
110
- ['--single-branch']
111
- when false
112
- ['--no-single-branch']
113
- else
114
- []
115
- end
116
- end
117
- },
118
- {
119
- keys: [:depth],
120
- type: :custom,
121
- builder: ->(value) { ['--depth', value.to_i] if value }
122
- }
123
- ].freeze
107
+ opts = opts.dup
108
+ opts[:separate_git_dir] ||= opts.delete(:repository)
109
+ Git::Commands::Init.new(self).call(**opts).stdout
110
+ end
124
111
 
125
112
  # Clones a repository into a newly created directory
126
113
  #
@@ -133,28 +120,42 @@ module Git
133
120
  #
134
121
  # @param [Hash] opts the options for this command
135
122
  #
136
- # @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
137
124
  #
138
- # @option opts [String] :branch the branch to checkout
125
+ # @option opts [String, nil] :branch (nil) the branch to checkout
139
126
  #
140
- # @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
141
128
  #
142
- # @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
143
130
  #
144
- # @option opts [String] :filter specify partial clone
131
+ # @option opts [String, nil] :filter (nil) specify partial clone
145
132
  #
146
- # @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
147
134
  #
148
- # @option opts [String] :origin the name of the remote
135
+ # @option opts [Logger, nil] :log (nil) Logger instance to use for git operations
149
136
  #
150
- # @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
151
138
  #
152
- # @option opts [String] :remote the name of the remote
139
+ # @option opts [String, nil] :origin (nil) the name of the remote
153
140
  #
154
- # @option opts [Boolean] :recursive after the clone is created, initialize all
155
- # within, using their default settings
141
+ # @option opts [String, nil] :chdir (nil) run `git clone` from this directory
156
142
  #
157
- # @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
158
159
  # command to complete
159
160
  #
160
161
  # See {Git::Lib#command} for more information about :timeout
@@ -163,16 +164,16 @@ module Git
163
164
  #
164
165
  # @todo make this work with SSH password or auth_key
165
166
  #
166
- def clone(repository_url, directory, opts = {})
167
- @path = opts[:path] || '.'
168
- clone_dir = opts[:path] ? File.join(@path, directory) : directory
169
-
170
- args = build_args(opts, CLONE_OPTION_MAP)
171
- args.push('--', repository_url, clone_dir)
172
-
173
- command('clone', *args, timeout: opts[:timeout])
174
-
175
- 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
176
177
  end
177
178
 
178
179
  # Returns the name of the default branch of the given repository
@@ -182,7 +183,7 @@ module Git
182
183
  # @return [String] the name of the default branch
183
184
  #
184
185
  def repository_default_branch(repository)
185
- output = command('ls-remote', '--symref', '--', repository, 'HEAD')
186
+ output = Git::Commands::LsRemote.new(self).call(repository, 'HEAD', symref: true).stdout
186
187
 
187
188
  match_data = output.match(%r{^ref: refs/remotes/origin/(?<default_branch>[^\t]+)\trefs/remotes/origin/HEAD$})
188
189
  return match_data[:default_branch] if match_data
@@ -195,29 +196,6 @@ module Git
195
196
 
196
197
  ## READ COMMANDS ##
197
198
 
198
- # The map defining how to translate user options to git command arguments.
199
- DESCRIBE_OPTION_MAP = [
200
- { keys: [:all], flag: '--all', type: :boolean },
201
- { keys: [:tags], flag: '--tags', type: :boolean },
202
- { keys: [:contains], flag: '--contains', type: :boolean },
203
- { keys: [:debug], flag: '--debug', type: :boolean },
204
- { keys: [:long], flag: '--long', type: :boolean },
205
- { keys: [:always], flag: '--always', type: :boolean },
206
- { keys: %i[exact_match exact-match], flag: '--exact-match', type: :boolean },
207
- { keys: [:abbrev], flag: '--abbrev', type: :valued_equals },
208
- { keys: [:candidates], flag: '--candidates', type: :valued_equals },
209
- { keys: [:match], flag: '--match', type: :valued_equals },
210
- {
211
- keys: [:dirty],
212
- type: :custom,
213
- builder: lambda do |value|
214
- return '--dirty' if value == true
215
-
216
- "--dirty=#{value}" if value.is_a?(String)
217
- end
218
- }
219
- ].freeze
220
-
221
199
  # Finds most recent tag that is reachable from a commit
222
200
  #
223
201
  # @see https://git-scm.com/docs/git-describe git-describe
@@ -226,13 +204,13 @@ module Git
226
204
  #
227
205
  # @param opts [Hash] the given options
228
206
  #
229
- # @option opts :all [Boolean]
230
- # @option opts :tags [Boolean]
231
- # @option opts :contains [Boolean]
232
- # @option opts :debug [Boolean]
233
- # @option opts :long [Boolean]
234
- # @option opts :always [Boolean]
235
- # @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
236
214
  # @option opts :dirty [true, String]
237
215
  # @option opts :abbrev [String]
238
216
  # @option opts :candidates [String]
@@ -245,59 +223,14 @@ module Git
245
223
  def describe(commit_ish = nil, opts = {})
246
224
  assert_args_are_not_options('commit-ish object', commit_ish)
247
225
 
248
- args = build_args(opts, DESCRIBE_OPTION_MAP)
249
- args << commit_ish if commit_ish
250
-
251
- command('describe', *args)
252
- end
253
-
254
- # Return the commits that are within the given revision range
255
- #
256
- # @see https://git-scm.com/docs/git-log git-log
257
- #
258
- # @param opts [Hash] the given options
259
- #
260
- # @option opts :count [Integer] the maximum number of commits to return (maps to max-count)
261
- # @option opts :all [Boolean]
262
- # @option opts :cherry [Boolean]
263
- # @option opts :since [String]
264
- # @option opts :until [String]
265
- # @option opts :grep [String]
266
- # @option opts :author [String]
267
- # @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range
268
- #
269
- # Only :between or :object options can be used, not both.
270
- #
271
- # @option opts :object [String] the revision range for the git log command
272
- #
273
- # Only :between or :object options can be used, not both.
274
- #
275
- # @option opts :path_limiter [String, Pathname, Array<String, Pathname>] only
276
- # include commits that impact files from the specified paths
277
- #
278
- # @return [Array<String>] the log output
279
- #
280
- # @raise [ArgumentError] if the resulting revision range is a string starting with a hyphen
281
- #
282
- def log_commits(opts = {})
283
- assert_args_are_not_options('between', opts[:between]&.first)
284
- assert_args_are_not_options('object', opts[:object])
285
-
286
- arr_opts = log_common_options(opts)
287
-
288
- arr_opts << '--pretty=oneline'
289
-
290
- 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')
291
229
 
292
- 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
293
232
  end
294
233
 
295
- FULL_LOG_EXTRA_OPTIONS_MAP = [
296
- { type: :static, flag: '--pretty=raw' },
297
- { keys: [:skip], flag: '--skip', type: :valued_equals },
298
- { keys: [:merges], flag: '--merges', type: :boolean }
299
- ].freeze
300
-
301
234
  # Return the commits that are within the given revision range
302
235
  #
303
236
  # @see https://git-scm.com/docs/git-log git-log
@@ -307,9 +240,9 @@ module Git
307
240
  # @option opts :count [Integer] the maximum number of commits to return (maps to
308
241
  # max-count)
309
242
  #
310
- # @option opts :all [Boolean]
243
+ # @option opts [Boolean, nil] :all (nil) include commits reachable from any ref
311
244
  #
312
- # @option opts :cherry [Boolean]
245
+ # @option opts [Boolean, nil] :cherry (nil) omit commits equivalent to cherry-picked commits
313
246
  #
314
247
  # @option opts :since [String]
315
248
  #
@@ -353,17 +286,11 @@ module Git
353
286
  # :object) is a string starting with a hyphen
354
287
  #
355
288
  def full_log_commits(opts = {})
356
- assert_args_are_not_options('between', opts[:between]&.first)
357
- assert_args_are_not_options('object', opts[:object])
289
+ assert_valid_opts(opts, FULL_LOG_ALLOWED_OPTS)
290
+ validate_log_count_option!(opts)
358
291
 
359
- args = log_common_options(opts)
360
- args += build_args(opts, FULL_LOG_EXTRA_OPTIONS_MAP)
361
- args += log_path_options(opts)
362
-
363
- log_or_empty_on_unborn do
364
- full_log = command_lines('log', *args)
365
- process_commit_log_data(full_log)
366
- 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)
367
294
  end
368
295
 
369
296
  # Verify and resolve a Git revision to its full SHA
@@ -382,12 +309,9 @@ module Git
382
309
  # @return [String] the full commit hash
383
310
  #
384
311
  # @raise [Git::FailedError] if the revision cannot be resolved
385
- # @raise [ArgumentError] if the revision is a string starting with a hyphen
386
312
  #
387
313
  def rev_parse(revision)
388
- assert_args_are_not_options('rev', revision)
389
-
390
- command('rev-parse', '--revs-only', '--end-of-options', revision, '--')
314
+ Git::Commands::RevParse.new(self).call(revision, '--', revs_only: true).stdout
391
315
  end
392
316
 
393
317
  # For backwards compatibility with the old method name
@@ -404,42 +328,69 @@ module Git
404
328
  def name_rev(commit_ish)
405
329
  assert_args_are_not_options('commit_ish', commit_ish)
406
330
 
407
- command('name-rev', commit_ish).split[1]
331
+ Git::Commands::NameRev.new(self).call(commit_ish).stdout.split[1]
408
332
  end
409
333
 
410
334
  alias namerev name_rev
411
335
 
412
- # 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.
413
341
  #
414
342
  # @see https://git-scm.com/docs/git-cat-file git-cat-file
415
343
  #
416
- # @example Get the contents of a file without a block
417
- # 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.
418
346
  #
419
- # @example Get the contents of a file with a block
420
- # 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.)
421
348
  #
422
- # @param object [String] the object whose contents to return
349
+ # @return [String] the raw content of the object
423
350
  #
424
- # @return [String] the object contents
351
+ # @raise [ArgumentError] if `object` starts with a hyphen
425
352
  #
426
- # @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) }
427
381
  #
428
382
  def cat_file_contents(object)
429
383
  assert_args_are_not_options('object', object)
430
384
 
431
- if block_given?
432
- Tempfile.create do |file|
433
- # If a block is given, write the output from the process to a temporary
434
- # file and then yield the file to the block
435
- #
436
- command('cat-file', '-p', object, out: file, err: file)
437
- file.rewind
438
- yield file
439
- end
440
- else
441
- # If a block is not given, return the file contents as a string
442
- 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
443
394
  end
444
395
  end
445
396
 
@@ -458,7 +409,7 @@ module Git
458
409
  def cat_file_type(object)
459
410
  assert_args_are_not_options('object', object)
460
411
 
461
- command('cat-file', '-t', object)
412
+ cat_file_object_meta(object)[:type]
462
413
  end
463
414
 
464
415
  alias object_type cat_file_type
@@ -467,16 +418,16 @@ module Git
467
418
  #
468
419
  # @see https://git-scm.com/docs/git-cat-file git-cat-file
469
420
  #
470
- # @param object [String] the object to get the type
421
+ # @param object [String] the object to get the size of
471
422
  #
472
- # @return [String] the object type
423
+ # @return [Integer] the object size in bytes
473
424
  #
474
425
  # @raise [ArgumentError] if object is a string starting with a hyphen
475
426
  #
476
427
  def cat_file_size(object)
477
428
  assert_args_are_not_options('object', object)
478
429
 
479
- command('cat-file', '-s', object).to_i
430
+ cat_file_object_meta(object)[:size]
480
431
  end
481
432
 
482
433
  alias object_size cat_file_size
@@ -502,7 +453,7 @@ module Git
502
453
  def cat_file_commit(object)
503
454
  assert_args_are_not_options('object', object)
504
455
 
505
- cdata = command_lines('cat-file', 'commit', object)
456
+ cdata = Git::Commands::CatFile::Raw.new(self).call('commit', object).stdout.split("\n")
506
457
  process_commit_data(cdata, object)
507
458
  end
508
459
 
@@ -519,6 +470,26 @@ module Git
519
470
 
520
471
  CAT_FILE_HEADER_LINE = /\A(?<key>\w+) (?<value>.*)\z/
521
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
+ #
522
493
  def each_cat_file_header(data)
523
494
  while (match = CAT_FILE_HEADER_LINE.match(data.shift))
524
495
  key = match[:key]
@@ -571,12 +542,39 @@ module Git
571
542
  def cat_file_tag(object)
572
543
  assert_args_are_not_options('object', object)
573
544
 
574
- tdata = command_lines('cat-file', 'tag', object)
545
+ tdata = Git::Commands::CatFile::Raw.new(self).call('tag', object).stdout.split("\n")
575
546
  process_tag_data(tdata, object)
576
547
  end
577
548
 
578
549
  alias tag_data cat_file_tag
579
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
+
580
578
  def process_tag_data(data, name)
581
579
  hsh = { 'name' => name }
582
580
 
@@ -651,32 +649,48 @@ module Git
651
649
  end
652
650
  private_constant :RawLogParser
653
651
 
654
- LS_TREE_OPTION_MAP = [
655
- { keys: [:recursive], flag: '-r', type: :boolean }
656
- ].freeze
652
+ # Allowed option keys for {#ls_tree}
653
+ LS_TREE_ALLOWED_OPTS = %i[recursive path].freeze
657
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
+ #
658
665
  def ls_tree(sha, opts = {})
659
- data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
660
- args = build_args(opts, LS_TREE_OPTION_MAP)
661
-
662
- args.unshift(sha)
663
- 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
664
674
 
665
- 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|
666
678
  (info, filenm) = split_status_line(line)
667
- (mode, type, sha) = info.split
668
- data[type][filenm] = { mode: mode, sha: sha }
679
+ (mode, type, entry_sha) = info.split
680
+ data[type][filenm] = { mode: mode, sha: entry_sha }
669
681
  end
670
-
671
682
  data
672
683
  end
684
+ private :parse_ls_tree_output
673
685
 
674
- def mv(file1, file2)
675
- 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
676
690
  end
677
691
 
678
692
  def full_tree(sha)
679
- command_lines('ls-tree', '-r', sha)
693
+ Git::Commands::LsTree.new(self).call(sha, r: true).stdout.split("\n")
680
694
  end
681
695
 
682
696
  def tree_depth(sha)
@@ -684,38 +698,12 @@ module Git
684
698
  end
685
699
 
686
700
  def change_head_branch(branch_name)
687
- command('symbolic-ref', 'HEAD', "refs/heads/#{branch_name}")
701
+ Git::Commands::SymbolicRef::Update.new(self).call('HEAD', "refs/heads/#{branch_name}")
688
702
  end
689
703
 
690
- BRANCH_LINE_REGEXP = /
691
- ^
692
- # Prefix indicates if this branch is checked out. The prefix is one of:
693
- (?:
694
- (?<current>\*[[:blank:]]) | # Current branch (checked out in the current worktree)
695
- (?<worktree>\+[[:blank:]]) | # Branch checked out in a different worktree
696
- [[:blank:]]{2} # Branch not checked out
697
- )
698
-
699
- # The branch's full refname
700
- (?:
701
- (?<not_a_branch>\(not[[:blank:]]a[[:blank:]]branch\)) |
702
- (?:\(HEAD[[:blank:]]detached[[:blank:]]at[[:blank:]](?<detached_ref>[^)]+)\)) |
703
- (?<refname>[^[[:blank:]]]+)
704
- )
705
-
706
- # Optional symref
707
- # If this ref is a symbolic reference, this is the ref referenced
708
- (?:
709
- [[:blank:]]->[[:blank:]](?<symref>.*)
710
- )?
711
- $
712
- /x
713
-
714
704
  def branches_all
715
- lines = command_lines('branch', '-a')
716
- lines.each_with_index.filter_map do |line, index|
717
- parse_branch_line(line, index, lines)
718
- 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)
719
707
  end
720
708
 
721
709
  def worktrees_all
@@ -730,7 +718,7 @@ module Git
730
718
  # HEAD b8c63206f8d10f57892060375a86ae911fad356e
731
719
  # detached
732
720
  #
733
- command_lines('worktree', 'list', '--porcelain').each do |w|
721
+ Git::Commands::Worktree::List.new(self).call(porcelain: true).stdout.split("\n").each do |w|
734
722
  s = w.split
735
723
  directory = s[1] if s[0] == 'worktree'
736
724
  arr << [directory, s[1]] if s[0] == 'HEAD'
@@ -739,17 +727,19 @@ module Git
739
727
  end
740
728
 
741
729
  def worktree_add(dir, commitish = nil)
742
- return worktree_command('worktree', 'add', dir, commitish) unless commitish.nil?
743
-
744
- 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
745
735
  end
746
736
 
747
737
  def worktree_remove(dir)
748
- worktree_command('worktree', 'remove', dir)
738
+ Git::Commands::Worktree::Remove.new(self).call(dir).stdout
749
739
  end
750
740
 
751
741
  def worktree_prune
752
- worktree_command('worktree', 'prune')
742
+ Git::Commands::Worktree::Prune.new(self).call.stdout
753
743
  end
754
744
 
755
745
  def list_files(ref_dir)
@@ -786,7 +776,7 @@ module Git
786
776
  # @return [HeadState] the state and name of the current branch
787
777
  #
788
778
  def current_branch_state
789
- branch_name = command('branch', '--show-current')
779
+ branch_name = Git::Commands::Branch::ShowCurrent.new(self).call.stdout
790
780
  return HeadState.new(:detached, 'HEAD') if branch_name.empty?
791
781
 
792
782
  state = get_branch_state(branch_name)
@@ -794,39 +784,36 @@ module Git
794
784
  end
795
785
 
796
786
  def branch_current
797
- branch_name = command('branch', '--show-current')
798
- 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
799
790
  end
800
791
 
801
792
  def branch_contains(commit, branch_name = '')
802
- 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
803
796
  end
804
797
 
805
- GREP_OPTION_MAP = [
806
- { keys: [:ignore_case], flag: '-i', type: :boolean },
807
- { keys: [:invert_match], flag: '-v', type: :boolean },
808
- { keys: [:extended_regexp], flag: '-E', type: :boolean },
809
- # For validation only, as these are handled manually
810
- { keys: [:object], type: :validate_only },
811
- { keys: [:path_limiter], type: :validate_only }
812
- ].freeze
798
+ GREP_ALLOWED_OPTS = %i[ignore_case i invert_match v extended_regexp E object path_limiter].freeze
813
799
 
814
- # returns hash
815
- # [tree-ish] = [[line_no, match], [line_no, match2]]
816
- # [tree-ish] = [[line_no, match], [line_no, match2]]
817
- def grep(string, opts = {})
818
- opts[:object] ||= 'HEAD'
819
- ArgsBuilder.validate!(opts, GREP_OPTION_MAP)
800
+ def grep(pattern, opts = {})
801
+ assert_valid_opts(opts, GREP_ALLOWED_OPTS)
820
802
 
821
- boolean_flags = build_args(opts, GREP_OPTION_MAP)
822
- 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
823
809
 
824
- if (limiter = opts[:path_limiter])
825
- args.push('--', *Array(limiter))
826
- 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
827
815
 
828
- lines = execute_grep_command(args)
829
- parse_grep_output(lines)
816
+ parse_grep_output(result.stdout)
830
817
  end
831
818
 
832
819
  # Validate that the given arguments cannot be mistaken for a command-line option
@@ -879,7 +866,24 @@ module Git
879
866
  raise ArgumentError, "Invalid #{arg_name}: must be a String, Pathname, or Array of Strings/Pathnames"
880
867
  end
881
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
+
882
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
+ #
883
887
  def handle_deprecated_path_option(opts)
884
888
  if opts.key?(:path_limiter)
885
889
  opts[:path_limiter]
@@ -891,74 +895,131 @@ module Git
891
895
  end
892
896
  end
893
897
 
894
- DIFF_FULL_OPTION_MAP = [
895
- { type: :static, flag: '-p' },
896
- { keys: [:path_limiter], type: :validate_only }
897
- ].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
898
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
+ #
899
930
  def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
900
- assert_args_are_not_options('commit or commit range', obj1, obj2)
901
- ArgsBuilder.validate!(opts, DIFF_FULL_OPTION_MAP)
902
-
903
- args = build_args(opts, DIFF_FULL_OPTION_MAP)
904
- args.push(obj1, obj2).compact!
905
-
906
- if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
907
- args.push('--', *pathspecs)
908
- end
909
-
910
- 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)
911
940
  end
912
941
 
913
- DIFF_STATS_OPTION_MAP = [
914
- { type: :static, flag: '--numstat' },
915
- { keys: [:path_limiter], type: :validate_only }
916
- ].freeze
917
-
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
+ #
918
962
  def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
919
- assert_args_are_not_options('commit or commit range', obj1, obj2)
920
- ArgsBuilder.validate!(opts, DIFF_STATS_OPTION_MAP)
921
-
922
- args = build_args(opts, DIFF_STATS_OPTION_MAP)
923
- args.push(obj1, obj2).compact!
924
-
925
- if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
926
- args.push('--', *pathspecs)
927
- end
928
-
929
- 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)
930
972
  parse_diff_stats_output(output_lines)
931
973
  end
932
974
 
933
- DIFF_PATH_STATUS_OPTION_MAP = [
934
- { type: :static, flag: '--name-status' },
935
- { keys: [:path_limiter], type: :validate_only },
936
- { keys: [:path], type: :validate_only }
937
- ].freeze
938
-
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
+ #
939
999
  def diff_path_status(reference1 = nil, reference2 = nil, opts = {})
940
- assert_args_are_not_options('commit or commit range', reference1, reference2)
941
- ArgsBuilder.validate!(opts, DIFF_PATH_STATUS_OPTION_MAP)
942
-
943
- args = build_args(opts, DIFF_PATH_STATUS_OPTION_MAP)
944
- args.push(reference1, reference2).compact!
1000
+ assert_valid_opts(opts, DIFF_PATH_STATUS_ALLOWED_OPTS)
945
1001
 
946
1002
  path_limiter = handle_deprecated_path_option(opts)
947
- if (pathspecs = normalize_pathspecs(path_limiter, 'path limiter'))
948
- args.push('--', *pathspecs)
949
- end
950
-
951
- 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)
952
1011
  end
953
1012
 
954
1013
  # compares the index and the working directory
955
1014
  def diff_files
956
- diff_as_hash('diff-files')
1015
+ Git::Commands::Status.new(self).call
1016
+ parse_raw_diff_output(Git::Commands::DiffFiles.new(self).call.stdout)
957
1017
  end
958
1018
 
959
1019
  # compares the index and the repository
960
1020
  def diff_index(treeish)
961
- 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)
962
1023
  end
963
1024
 
964
1025
  # List all files that are in the index
@@ -976,7 +1037,7 @@ module Git
976
1037
  def ls_files(location = nil)
977
1038
  location ||= '.'
978
1039
  {}.tap do |files|
979
- 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|
980
1041
  (info, file) = split_status_line(line)
981
1042
  (mode, sha, stage) = info.split
982
1043
  files[file] = {
@@ -1009,28 +1070,22 @@ module Git
1009
1070
  end
1010
1071
  end
1011
1072
 
1012
- LS_REMOTE_OPTION_MAP = [
1013
- { keys: [:refs], flag: '--refs', type: :boolean }
1014
- ].freeze
1015
-
1016
1073
  def ls_remote(location = nil, opts = {})
1017
- ArgsBuilder.validate!(opts, LS_REMOTE_OPTION_MAP)
1018
-
1019
- flags = build_args(opts, LS_REMOTE_OPTION_MAP)
1020
- positional_arg = location || '.'
1021
-
1022
- 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")
1023
1076
  parse_ls_remote_output(output_lines)
1024
1077
  end
1025
1078
 
1026
1079
  def ignored_files
1027
- 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) }
1028
1083
  end
1029
1084
 
1030
1085
  def untracked_files
1031
- command_lines('ls-files', '--others', '--exclude-standard', chdir: @git_work_dir).map do |f|
1032
- unescape_quoted_path(f)
1033
- 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) }
1034
1089
  end
1035
1090
 
1036
1091
  def config_remote(name)
@@ -1042,19 +1097,25 @@ module Git
1042
1097
  end
1043
1098
 
1044
1099
  def config_get(name)
1045
- 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
1046
1104
  end
1047
1105
 
1048
1106
  def global_config_get(name)
1049
- 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
1050
1111
  end
1051
1112
 
1052
1113
  def config_list
1053
- parse_config_list command_lines('config', '--list', chdir: @git_dir)
1114
+ parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call.stdout.split("\n")
1054
1115
  end
1055
1116
 
1056
1117
  def global_config_list
1057
- parse_config_list command_lines('config', '--global', '--list')
1118
+ parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call(global: true).stdout.split("\n")
1058
1119
  end
1059
1120
 
1060
1121
  def parse_config_list(lines)
@@ -1067,7 +1128,7 @@ module Git
1067
1128
  end
1068
1129
 
1069
1130
  def parse_config(file)
1070
- 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")
1071
1132
  end
1072
1133
 
1073
1134
  # Shows objects
@@ -1076,34 +1137,23 @@ module Git
1076
1137
  # @param [String|NilClass] path the path of the file to be shown
1077
1138
  # @return [String] the object information
1078
1139
  def show(objectish = nil, path = nil)
1079
- arr_opts = []
1080
-
1081
- arr_opts << (path ? "#{objectish}:#{path}" : objectish)
1082
-
1083
- command('show', *arr_opts.compact, chomp: false)
1140
+ object = path ? "#{objectish}:#{path}" : objectish
1141
+ Git::Commands::Show.new(self).call(*[object].compact).stdout
1084
1142
  end
1085
1143
 
1086
1144
  ## WRITE COMMANDS ##
1087
1145
 
1088
- CONFIG_SET_OPTION_MAP = [
1089
- { keys: [:file], flag: '--file', type: :valued_space }
1090
- ].freeze
1146
+ CONFIG_SET_ALLOWED_OPTS = %i[file].freeze
1091
1147
 
1092
1148
  def config_set(name, value, options = {})
1093
- ArgsBuilder.validate!(options, CONFIG_SET_OPTION_MAP)
1094
- flags = build_args(options, CONFIG_SET_OPTION_MAP)
1095
- 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))
1096
1151
  end
1097
1152
 
1098
1153
  def global_config_set(name, value)
1099
- command('config', '--global', name, value)
1154
+ Git::Commands::ConfigOptionSyntax::Set.new(self).call(name, value, global: true)
1100
1155
  end
1101
1156
 
1102
- ADD_OPTION_MAP = [
1103
- { keys: [:all], flag: '--all', type: :boolean },
1104
- { keys: [:force], flag: '--force', type: :boolean }
1105
- ].freeze
1106
-
1107
1157
  # Update the index from the current worktree to prepare the for the next commit
1108
1158
  #
1109
1159
  # @example
@@ -1114,31 +1164,28 @@ module Git
1114
1164
  # @param [String, Array<String>] paths files to be added to the repository (relative to the worktree root)
1115
1165
  # @param [Hash] options
1116
1166
  #
1117
- # @option options [Boolean] :all Add, modify, and remove index entries to match the worktree
1118
- # @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)
1119
1171
  #
1120
1172
  def add(paths = '.', options = {})
1121
- args = build_args(options, ADD_OPTION_MAP)
1122
-
1123
- args << '--'
1124
- args.concat(Array(paths))
1125
-
1126
- command('add', *args)
1173
+ Git::Commands::Add.new(self).call(*Array(paths), **options).stdout
1127
1174
  end
1128
1175
 
1129
- RM_OPTION_MAP = [
1130
- { type: :static, flag: '-f' },
1131
- { keys: [:recursive], flag: '-r', type: :boolean },
1132
- { keys: [:cached], flag: '--cached', type: :boolean }
1133
- ].freeze
1134
-
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
+ #
1135
1187
  def rm(path = '.', opts = {})
1136
- args = build_args(opts, RM_OPTION_MAP)
1137
-
1138
- args << '--'
1139
- args.concat(Array(path))
1140
-
1141
- command('rm', *args)
1188
+ Git::Commands::Rm.new(self).call(*Array(path), **opts).stdout
1142
1189
  end
1143
1190
 
1144
1191
  # Returns true if the repository is empty (meaning it has no commits)
@@ -1146,7 +1193,7 @@ module Git
1146
1193
  # @return [Boolean]
1147
1194
  #
1148
1195
  def empty?
1149
- command('rev-parse', '--verify', 'HEAD')
1196
+ Git::Commands::RevParse.new(self).call('HEAD', verify: true)
1150
1197
  false
1151
1198
  rescue Git::FailedError => e
1152
1199
  raise unless e.result.status.exitstatus == 128 &&
@@ -1155,27 +1202,6 @@ module Git
1155
1202
  true
1156
1203
  end
1157
1204
 
1158
- COMMIT_OPTION_MAP = [
1159
- { keys: %i[add_all all], flag: '--all', type: :boolean },
1160
- { keys: [:allow_empty], flag: '--allow-empty', type: :boolean },
1161
- { keys: [:no_verify], flag: '--no-verify', type: :boolean },
1162
- { keys: [:allow_empty_message], flag: '--allow-empty-message', type: :boolean },
1163
- { keys: [:author], flag: '--author', type: :valued_equals },
1164
- { keys: [:message], flag: '--message', type: :valued_equals },
1165
- { keys: [:no_gpg_sign], flag: '--no-gpg-sign', type: :boolean },
1166
- { keys: [:date], flag: '--date', type: :valued_equals, validator: ->(v) { v.is_a?(String) } },
1167
- { keys: [:amend], type: :custom, builder: ->(value) { ['--amend', '--no-edit'] if value } },
1168
- {
1169
- keys: [:gpg_sign],
1170
- type: :custom,
1171
- builder: lambda { |value|
1172
- if value
1173
- value == true ? '--gpg-sign' : "--gpg-sign=#{value}"
1174
- end
1175
- }
1176
- }
1177
- ].freeze
1178
-
1179
1205
  # Takes the commit message with the options and executes the commit command
1180
1206
  #
1181
1207
  # accepts options:
@@ -1191,176 +1217,284 @@ module Git
1191
1217
  #
1192
1218
  # @param [String] message the commit message to be used
1193
1219
  # @param [Hash] opts the commit options to be used
1194
-
1220
+ #
1195
1221
  def commit(message, opts = {})
1196
- opts[:message] = message if message # Handle message arg for backward compatibility
1197
-
1198
- # Perform cross-option validation before building args
1199
- raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign' if opts[:gpg_sign] && opts[:no_gpg_sign]
1200
-
1201
- ArgsBuilder.validate!(opts, COMMIT_OPTION_MAP)
1202
-
1203
- args = build_args(opts, COMMIT_OPTION_MAP)
1204
- 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
1205
1225
  end
1206
- RESET_OPTION_MAP = [
1207
- { keys: [:hard], flag: '--hard', type: :boolean }
1208
- ].freeze
1209
1226
 
1210
- def reset(commit, opts = {})
1211
- args = build_args(opts, RESET_OPTION_MAP)
1212
- args << commit if commit
1213
- 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
1214
1231
  end
1215
1232
 
1216
- CLEAN_OPTION_MAP = [
1217
- { keys: [:force], flag: '--force', type: :boolean },
1218
- { keys: [:ff], flag: '-ff', type: :boolean },
1219
- { keys: [:d], flag: '-d', type: :boolean },
1220
- { keys: [:x], flag: '-x', type: :boolean }
1221
- ].freeze
1222
-
1233
+ # @return [String] the command output
1234
+ #
1223
1235
  def clean(opts = {})
1224
- args = build_args(opts, CLEAN_OPTION_MAP)
1225
- command('clean', *args)
1236
+ opts = migrate_clean_legacy_options(opts)
1237
+ Git::Commands::Clean.new(self).call(**opts).stdout
1226
1238
  end
1227
1239
 
1228
- REVERT_OPTION_MAP = [
1229
- { keys: [:no_edit], flag: '--no-edit', type: :boolean }
1230
- ].freeze
1240
+ REVERT_ALLOWED_OPTS = %i[no_edit].freeze
1231
1241
 
1232
1242
  def revert(commitish, opts = {})
1233
- # Forcing --no-edit as default since it's not an interactive session.
1243
+ assert_valid_opts(opts, REVERT_ALLOWED_OPTS)
1234
1244
  opts = { no_edit: true }.merge(opts)
1235
-
1236
- args = build_args(opts, REVERT_OPTION_MAP)
1237
- args << commitish
1238
-
1239
- command('revert', *args)
1245
+ Git::Commands::Revert::Start.new(self).call(commitish, **opts).stdout
1240
1246
  end
1241
1247
 
1242
1248
  def apply(patch_file)
1243
- arr_opts = []
1244
- arr_opts << '--' << patch_file if patch_file
1245
- command('apply', *arr_opts)
1249
+ Git::Commands::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1246
1250
  end
1247
1251
 
1248
1252
  def apply_mail(patch_file)
1249
- arr_opts = []
1250
- arr_opts << '--' << patch_file if patch_file
1251
- command('am', *arr_opts)
1253
+ Git::Commands::Am::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1252
1254
  end
1253
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
+ #
1254
1271
  def stashes_all
1255
- stash_log_lines.each_with_index.map do |line, index|
1256
- parse_stash_log_line(line, index)
1257
- 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) }
1258
1275
  end
1259
1276
 
1260
- def stash_save(message)
1261
- output = command('stash', 'save', message)
1262
- 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')
1263
1294
  end
1264
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
+ #
1265
1312
  def stash_apply(id = nil)
1266
- if id
1267
- command('stash', 'apply', id)
1268
- else
1269
- command('stash', 'apply')
1270
- end
1313
+ result = Git::Commands::Stash::Apply.new(self).call(id)
1314
+ result.stdout
1271
1315
  end
1272
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
+ #
1273
1328
  def stash_clear
1274
- command('stash', 'clear')
1329
+ result = Git::Commands::Stash::Clear.new(self).call
1330
+ result.stdout
1275
1331
  end
1276
1332
 
1277
- def stash_list
1278
- 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")
1279
1350
  end
1280
1351
 
1281
- def branch_new(branch)
1282
- 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
1283
1363
  end
1284
1364
 
1285
- def branch_delete(branch)
1286
- command('branch', '-D', branch)
1287
- 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)
1288
1379
 
1289
- CHECKOUT_OPTION_MAP = [
1290
- { keys: %i[force f], flag: '--force', type: :boolean },
1291
- { keys: %i[new_branch b], type: :validate_only },
1292
- { keys: [:start_point], type: :validate_only }
1293
- ].freeze
1380
+ raise Git::Error, result.stderr.strip unless result.status.success?
1381
+
1382
+ result.stdout.strip
1383
+ end
1294
1384
 
1295
1385
  # Runs checkout command to checkout or create branch
1296
1386
  #
1297
1387
  # accepts options:
1298
- # :new_branch
1299
- # :force
1300
- # :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
1301
1395
  #
1302
- # @param [String] branch
1303
- # @param [Hash] opts
1304
1396
  def checkout(branch = nil, opts = {})
1305
1397
  if branch.is_a?(Hash) && opts.empty?
1306
1398
  opts = branch
1307
1399
  branch = nil
1308
1400
  end
1309
- ArgsBuilder.validate!(opts, CHECKOUT_OPTION_MAP)
1310
1401
 
1311
- flags = build_args(opts, CHECKOUT_OPTION_MAP)
1312
- 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
1313
1405
 
1314
- 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
1315
1417
  end
1418
+ private :translate_checkout_opts
1316
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
+ #
1317
1426
  def checkout_file(version, file)
1318
- arr_opts = []
1319
- arr_opts << version
1320
- arr_opts << file
1321
- command('checkout', *arr_opts)
1427
+ Git::Commands::Checkout::Files.new(self).call(version, pathspec: [file]).stdout
1322
1428
  end
1323
1429
 
1324
- MERGE_OPTION_MAP = [
1325
- { keys: [:no_commit], flag: '--no-commit', type: :boolean },
1326
- { keys: [:no_ff], flag: '--no-ff', type: :boolean },
1327
- { keys: [:m], flag: '-m', type: :valued_space }
1328
- ].freeze
1329
-
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
+ #
1330
1452
  def merge(branch, message = nil, opts = {})
1331
- # For backward compatibility, treat the message arg as the :m option.
1332
- opts[:m] = message if message
1333
- ArgsBuilder.validate!(opts, MERGE_OPTION_MAP)
1453
+ # Handle legacy positional message argument
1454
+ opts = opts.merge(message: message) if message
1334
1455
 
1335
- args = build_args(opts, MERGE_OPTION_MAP)
1336
- args.concat(Array(branch))
1456
+ # Map legacy option names to new interface
1457
+ opts = translate_merge_options(opts)
1337
1458
 
1338
- command('merge', *args)
1459
+ Git::Commands::Merge::Start.new(self).call(*Array(branch), no_edit: true, **opts).stdout
1339
1460
  end
1340
1461
 
1341
- MERGE_BASE_OPTION_MAP = [
1342
- { keys: [:octopus], flag: '--octopus', type: :boolean },
1343
- { keys: [:independent], flag: '--independent', type: :boolean },
1344
- { keys: [:fork_point], flag: '--fork-point', type: :boolean },
1345
- { keys: [:all], flag: '--all', type: :boolean }
1346
- ].freeze
1347
-
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
+ #
1348
1477
  def merge_base(*args)
1349
1478
  opts = args.last.is_a?(Hash) ? args.pop : {}
1350
- ArgsBuilder.validate!(opts, MERGE_BASE_OPTION_MAP)
1351
-
1352
- flags = build_args(opts, MERGE_BASE_OPTION_MAP)
1353
- command_args = flags + args
1354
-
1355
- 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?)
1356
1481
  end
1357
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
+ #
1358
1493
  def unmerged
1359
- unmerged = []
1360
- command_lines('diff', '--cached').each do |line|
1361
- 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 (.*)/
1362
1497
  end
1363
- unmerged
1364
1498
  end
1365
1499
 
1366
1500
  def conflicts # :yields: file, your, their
@@ -1376,416 +1510,961 @@ module Git
1376
1510
  end
1377
1511
  end
1378
1512
 
1379
- REMOTE_ADD_OPTION_MAP = [
1380
- { keys: %i[with_fetch fetch], flag: '-f', type: :boolean },
1381
- { keys: [:track], flag: '-t', type: :valued_space }
1382
- ].freeze
1383
-
1384
1513
  def remote_add(name, url, opts = {})
1385
- ArgsBuilder.validate!(opts, REMOTE_ADD_OPTION_MAP)
1386
-
1387
- flags = build_args(opts, REMOTE_ADD_OPTION_MAP)
1388
- positional_args = ['--', name, url]
1389
- 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)
1390
1516
 
1391
- command('remote', *command_args)
1517
+ Git::Commands::Remote::Add.new(self).call(name, url, **translated_opts)
1392
1518
  end
1393
1519
 
1394
- REMOTE_SET_BRANCHES_OPTION_MAP = [
1395
- { keys: [:add], flag: '--add', type: :boolean }
1396
- ].freeze
1397
-
1398
1520
  def remote_set_branches(name, branches, opts = {})
1399
- ArgsBuilder.validate!(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1400
-
1401
- flags = build_args(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1402
- branch_args = Array(branches).flatten
1403
- command_args = ['set-branches'] + flags + [name] + branch_args
1404
-
1405
- command('remote', *command_args)
1521
+ Git::Commands::Remote::SetBranches.new(self).call(name, *Array(branches).flatten, **opts)
1406
1522
  end
1407
1523
 
1408
- def remote_set_url(name, url)
1409
- arr_opts = ['set-url']
1410
- arr_opts << name
1411
- arr_opts << url
1412
-
1413
- command('remote', *arr_opts)
1524
+ def remote_set_url(name, url, opts = {})
1525
+ Git::Commands::Remote::SetUrl.new(self).call(name, url, **opts)
1414
1526
  end
1415
1527
 
1416
1528
  def remote_remove(name)
1417
- command('remote', 'rm', name)
1529
+ Git::Commands::Remote::Remove.new(self).call(name)
1418
1530
  end
1419
1531
 
1420
1532
  def remotes
1421
- command_lines('remote')
1533
+ Git::Commands::Remote::List.new(self).call.stdout.split("\n")
1422
1534
  end
1423
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
+ #
1424
1542
  def tags
1425
- 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)
1426
1545
  end
1427
1546
 
1428
- TAG_OPTION_MAP = [
1429
- { keys: %i[force f], flag: '-f', type: :boolean },
1430
- { keys: %i[annotate a], flag: '-a', type: :boolean },
1431
- { keys: %i[sign s], flag: '-s', type: :boolean },
1432
- { keys: %i[delete d], flag: '-d', type: :boolean },
1433
- { keys: %i[message m], flag: '-m', type: :valued_space }
1434
- ].freeze
1435
-
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
+ #
1436
1627
  def tag(name, *args)
1437
1628
  opts = args.last.is_a?(Hash) ? args.pop : {}
1438
1629
  target = args.first
1439
1630
 
1440
- validate_tag_options!(opts)
1441
- 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
1442
1645
 
1443
- flags = build_args(opts, TAG_OPTION_MAP)
1444
- positional_args = [name, target].compact
1646
+ PUSH_ALLOWED_OPTS = %i[mirror delete force f push_option all tags].freeze
1445
1647
 
1446
- command('tag', *flags, *positional_args)
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)
1739
+
1740
+ first_result = push_refs(remote, branch, opts)
1741
+ return first_result.stdout unless push_tags_separately?(opts)
1742
+
1743
+ push_tags(remote, opts).stdout
1447
1744
  end
1448
1745
 
1449
- FETCH_OPTION_MAP = [
1450
- { keys: [:all], flag: '--all', type: :boolean },
1451
- { keys: %i[tags t], flag: '--tags', type: :boolean },
1452
- { keys: %i[prune p], flag: '--prune', type: :boolean },
1453
- { keys: %i[prune-tags P], flag: '--prune-tags', type: :boolean },
1454
- { keys: %i[force f], flag: '--force', type: :boolean },
1455
- { keys: %i[update-head-ok u], flag: '--update-head-ok', type: :boolean },
1456
- { keys: [:unshallow], flag: '--unshallow', type: :boolean },
1457
- { keys: [:depth], flag: '--depth', type: :valued_space },
1458
- { keys: [:ref], type: :validate_only }
1459
- ].freeze
1746
+ PULL_ALLOWED_OPTS = %i[allow_unrelated_histories].freeze
1460
1747
 
1461
- def fetch(remote, opts)
1462
- ArgsBuilder.validate!(opts, FETCH_OPTION_MAP)
1463
- 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
1756
+
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
1464
1781
 
1465
- if remote || opts[:ref]
1466
- args << '--'
1467
- args << remote if remote
1468
- args << opts[:ref] if opts[:ref]
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)
1469
1868
  end
1869
+ apply_gzip(file) if gzip
1470
1870
 
1471
- command('fetch', *args, merge: true)
1871
+ file
1472
1872
  end
1473
1873
 
1474
- PUSH_OPTION_MAP = [
1475
- { keys: [:mirror], flag: '--mirror', type: :boolean },
1476
- { keys: [:delete], flag: '--delete', type: :boolean },
1477
- { keys: %i[force f], flag: '--force', type: :boolean },
1478
- { keys: [:push_option], flag: '--push-option', type: :repeatable_valued_space },
1479
- { keys: [:all], type: :validate_only }, # For validation purposes
1480
- { keys: [:tags], type: :validate_only } # From the `push` method's logic
1481
- ].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
1482
1895
 
1483
- def push(remote = nil, branch = nil, opts = nil)
1484
- remote, branch, opts = normalize_push_args(remote, branch, opts)
1485
- 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
1486
1907
 
1487
- 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
1488
1917
 
1489
- 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
1490
1931
 
1491
- if opts[:mirror]
1492
- command('push', *args)
1493
- else
1494
- command('push', *args)
1495
- 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."
1496
2001
  end
2002
+ true
1497
2003
  end
1498
2004
 
1499
- PULL_OPTION_MAP = [
1500
- { keys: [:allow_unrelated_histories], flag: '--allow-unrelated-histories', type: :boolean }
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
2017
+
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
1501
2030
  ].freeze
1502
2031
 
1503
- def pull(remote = nil, branch = nil, opts = {})
1504
- raise ArgumentError, 'You must specify a remote if a branch is specified' if remote.nil? && !branch.nil?
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
2117
+
2118
+ extra_options = options_hash.keys - COMMAND_CAPTURING_ARG_DEFAULTS.keys
2119
+ raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
1505
2120
 
1506
- ArgsBuilder.validate!(opts, PULL_OPTION_MAP)
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)
2124
+ end
2125
+
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
1507
2191
 
1508
- flags = build_args(opts, PULL_OPTION_MAP)
1509
- positional_args = [remote, branch].compact
2192
+ extra_options = options_hash.keys - COMMAND_STREAMING_ARG_DEFAULTS.keys
2193
+ raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
1510
2194
 
1511
- command('pull', *flags, *positional_args)
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)
1512
2198
  end
1513
2199
 
1514
- def tag_sha(tag_name)
1515
- head = File.join(@git_dir, 'refs', 'tags', tag_name)
1516
- return File.read(head).chomp if File.exist?(head)
1517
-
1518
- begin
1519
- command('show-ref', '--tags', '-s', tag_name)
1520
- rescue Git::FailedError => e
1521
- raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
1522
-
1523
- ''
1524
- end
1525
- end
2200
+ private
1526
2201
 
1527
- def repack
1528
- command('repack', '-a', '-d')
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.')
1529
2205
  end
1530
2206
 
1531
- def gc
1532
- command('gc', '--prune', '--aggressive', '--auto')
1533
- end
1534
-
1535
- FSCK_OPTION_MAP = [
1536
- { flag: '--no-progress', type: :static },
1537
- { keys: [:unreachable], flag: '--unreachable', type: :boolean },
1538
- { keys: [:strict], flag: '--strict', type: :boolean },
1539
- { keys: [:connectivity_only], flag: '--connectivity-only', type: :boolean },
1540
- { keys: [:root], flag: '--root', type: :boolean },
1541
- { keys: [:tags], flag: '--tags', type: :boolean },
1542
- { keys: [:cache], flag: '--cache', type: :boolean },
1543
- { keys: [:no_reflogs], flag: '--no-reflogs', type: :boolean },
1544
- { keys: [:lost_found], flag: '--lost-found', type: :boolean },
1545
- { keys: [:dangling], flag: '--dangling', type: :boolean_negatable },
1546
- { keys: [:full], flag: '--full', type: :boolean_negatable },
1547
- { keys: [:name_objects], flag: '--name-objects', type: :boolean_negatable },
1548
- { keys: [:references], flag: '--references', type: :boolean_negatable }
1549
- ].freeze
2207
+ def deprecate_clean_option(opts, key, message)
2208
+ return opts unless opts.key?(key)
1550
2209
 
1551
- def fsck(*objects, **opts)
1552
- args = ArgsBuilder.build(opts, FSCK_OPTION_MAP)
1553
- args.concat(objects) unless objects.empty?
1554
- # fsck returns non-zero exit status when issues are found:
1555
- # 1 = errors found, 2 = missing objects, 4 = warnings
1556
- # We still want to parse the output in these cases
1557
- output = begin
1558
- command('fsck', *args)
1559
- rescue Git::FailedError => e
1560
- raise unless [1, 2, 4].include?(e.result.status.exitstatus)
1561
-
1562
- e.result.stdout
1563
- end
1564
- parse_fsck_output(output)
1565
- end
2210
+ opts = opts.dup
2211
+ deprecated_value = opts.delete(key)
2212
+ validate_deprecated_clean_option_value!(key, deprecated_value)
1566
2213
 
1567
- READ_TREE_OPTION_MAP = [
1568
- { keys: [:prefix], flag: '--prefix', type: :valued_equals }
1569
- ].freeze
2214
+ Git::Deprecation.warn(message)
2215
+ return opts unless deprecated_value
1570
2216
 
1571
- def read_tree(treeish, opts = {})
1572
- ArgsBuilder.validate!(opts, READ_TREE_OPTION_MAP)
1573
- flags = build_args(opts, READ_TREE_OPTION_MAP)
1574
- command('read-tree', *flags, treeish)
2217
+ opts[:force] = merge_clean_force_option(opts[:force], force_specified: force_option_specified?(opts))
2218
+ opts
1575
2219
  end
1576
2220
 
1577
- def write_tree
1578
- command('write-tree')
2221
+ def force_option_specified?(opts)
2222
+ opts.key?(:force) && !opts[:force].nil?
1579
2223
  end
1580
2224
 
1581
- COMMIT_TREE_OPTION_MAP = [
1582
- { keys: %i[parent parents], flag: '-p', type: :repeatable_valued_space },
1583
- { keys: [:message], flag: '-m', type: :valued_space }
1584
- ].freeze
1585
-
1586
- def commit_tree(tree, opts = {})
1587
- opts[:message] ||= "commit tree #{tree}"
1588
- ArgsBuilder.validate!(opts, COMMIT_TREE_OPTION_MAP)
1589
-
1590
- flags = build_args(opts, COMMIT_TREE_OPTION_MAP)
1591
- command('commit-tree', tree, *flags)
1592
- end
2225
+ def validate_deprecated_clean_option_value!(key, value)
2226
+ return if value.nil? || value == true || value == false
1593
2227
 
1594
- def update_ref(ref, commit)
1595
- command('update-ref', ref, commit)
2228
+ raise ArgumentError, "#{key} option only accepts true, false, or nil"
1596
2229
  end
1597
2230
 
1598
- CHECKOUT_INDEX_OPTION_MAP = [
1599
- { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1600
- { keys: [:force], flag: '--force', type: :boolean },
1601
- { keys: [:all], flag: '--all', type: :boolean },
1602
- { keys: [:path_limiter], type: :validate_only }
1603
- ].freeze
2231
+ def merge_clean_force_option(existing_force, force_specified: false)
2232
+ return 2 unless force_specified
1604
2233
 
1605
- def checkout_index(opts = {})
1606
- ArgsBuilder.validate!(opts, CHECKOUT_INDEX_OPTION_MAP)
1607
- args = build_args(opts, CHECKOUT_INDEX_OPTION_MAP)
2234
+ normalized_force = normalize_clean_force_option(existing_force)
1608
2235
 
1609
- if (path = opts[:path_limiter]) && path.is_a?(String)
1610
- 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
1611
2242
  end
1612
-
1613
- command('checkout-index', *args)
1614
2243
  end
1615
2244
 
1616
- ARCHIVE_OPTION_MAP = [
1617
- { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1618
- { keys: [:remote], flag: '--remote', type: :valued_equals },
1619
- # These options are used by helpers or handled manually
1620
- { keys: [:path], type: :validate_only },
1621
- { keys: [:format], type: :validate_only },
1622
- { keys: [:add_gzip], type: :validate_only }
1623
- ].freeze
1624
-
1625
- def archive(sha, file = nil, opts = {})
1626
- ArgsBuilder.validate!(opts, ARCHIVE_OPTION_MAP)
1627
- file ||= temp_file_name
1628
- format, gzip = parse_archive_format_options(opts)
1629
-
1630
- args = build_args(opts, ARCHIVE_OPTION_MAP)
1631
- args.unshift("--format=#{format}")
1632
- args << sha
1633
- args.push('--', opts[:path]) if opts[:path]
1634
-
1635
- File.open(file, 'wb') { |f| command('archive', *args, out: f) }
1636
- apply_gzip(file) if gzip
2245
+ def merge_integer_clean_force_option(normalized_force)
2246
+ return normalized_force if normalized_force < 1
1637
2247
 
1638
- file
2248
+ [normalized_force, 2].max
1639
2249
  end
1640
2250
 
1641
- # returns the current version of git, as an Array of Fixnums.
1642
- def current_command_version
1643
- output = command('version')
1644
- version = output[/\d+(\.\d+)+/]
1645
- version_parts = version.split('.').collect(&:to_i)
1646
- 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
1647
2256
  end
1648
2257
 
1649
- # Returns current_command_version <=> other_version
2258
+ # Build a result hash from clone options for Git::Base.new
1650
2259
  #
1651
- # @example
1652
- # 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>'...
1653
2264
  #
1654
- # lib.compare_version_to(2, 41, 0) #=> 1
1655
- # lib.compare_version_to(2, 42, 0) #=> 0
1656
- # lib.compare_version_to(2, 43, 0) #=> -1
2265
+ # @param command_line_result [Git::CommandLineResult] the result of the git clone command
1657
2266
  #
1658
- # @param other_version [Array<Object>] the other version to compare to
1659
- # @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)
1660
2268
  #
1661
- def compare_version_to(*other_version)
1662
- 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
1663
2277
  end
1664
2278
 
1665
- def required_command_version
1666
- [2, 28]
1667
- 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
1668
2301
 
1669
- def meets_required_version?
1670
- (current_command_version <=> required_command_version) >= 0
2302
+ [match[2], !match[1].nil?]
1671
2303
  end
1672
2304
 
1673
- def self.warn_if_old_command(lib) # rubocop:disable Naming/PredicateMethod
1674
- Git::Deprecation.warn('Git::Lib#warn_if_old_command is deprecated. Use meets_required_version?.')
1675
-
1676
- 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
1677
2317
 
1678
- @version_checked = true
1679
- unless lib.meets_required_version?
1680
- warn "[WARNING] The git gem requires git #{lib.required_command_version.join('.')} or later, " \
1681
- "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)
1682
2320
  end
1683
- true
1684
2321
  end
1685
2322
 
1686
- COMMAND_ARG_DEFAULTS = {
1687
- out: nil,
1688
- err: nil,
1689
- normalize: true,
1690
- chomp: true,
1691
- merge: false,
1692
- chdir: nil,
1693
- timeout: nil # Don't set to Git.config.timeout here since it is mutable
1694
- }.freeze
1695
-
1696
- STATIC_GLOBAL_OPTS = %w[
1697
- -c core.quotePath=true
1698
- -c color.ui=false
1699
- -c color.advice=false
1700
- -c color.diff=false
1701
- -c color.grep=false
1702
- -c color.push=false
1703
- -c color.remote=false
1704
- -c color.showBranch=false
1705
- -c color.status=false
1706
- -c color.transport=false
1707
- ].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)
1708
2333
 
1709
- LOG_OPTION_MAP = [
1710
- { type: :static, flag: '--no-color' },
1711
- { keys: [:all], flag: '--all', type: :boolean },
1712
- { keys: [:cherry], flag: '--cherry', type: :boolean },
1713
- { keys: [:since], flag: '--since', type: :valued_equals },
1714
- { keys: [:until], flag: '--until', type: :valued_equals },
1715
- { keys: [:grep], flag: '--grep', type: :valued_equals },
1716
- { keys: [:author], flag: '--author', type: :valued_equals },
1717
- { keys: [:count], flag: '--max-count', type: :valued_equals },
1718
- { keys: [:between], type: :custom, builder: ->(value) { "#{value[0]}..#{value[1]}" if value } }
1719
- ].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
1720
2338
 
1721
- FSCK_OBJECT_PATTERN = /\A(dangling|missing|unreachable) (\w+) ([0-9a-f]{40})(?: \((.+)\))?\z/
1722
- FSCK_WARNING_PATTERN = /\Awarning in (\w+) ([0-9a-f]{40}): (.+)\z/
1723
- FSCK_ROOT_PATTERN = /\Aroot ([0-9a-f]{40})\z/
1724
- 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)
1725
2341
 
1726
- 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
1727
2345
 
1728
- private
2346
+ def deprecate_clone_remote_option!(opts)
2347
+ return unless opts.key?(:remote)
1729
2348
 
1730
- def parse_fsck_output(output)
1731
- result = { dangling: [], missing: [], unreachable: [], warnings: [], root: [], tagged: [] }
1732
- output.each_line { |line| parse_fsck_line(line.strip, result) }
1733
- 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)
1734
2351
  end
1735
2352
 
1736
- def parse_fsck_line(line, result)
1737
- parse_fsck_object_line(line, result) ||
1738
- parse_fsck_warning_line(line, result) ||
1739
- parse_fsck_root_line(line, result) ||
1740
- 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)
1741
2357
  end
1742
2358
 
1743
- def parse_fsck_object_line(line, result)
1744
- return unless (match = FSCK_OBJECT_PATTERN.match(line))
2359
+ def deprecate_commit_add_all_option!(opts)
2360
+ return unless opts.key?(:add_all)
1745
2361
 
1746
- 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)
1747
2364
  end
1748
2365
 
1749
- def parse_fsck_warning_line(line, result)
1750
- return unless (match = FSCK_WARNING_PATTERN.match(line))
1751
-
1752
- 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
1753
2376
  end
1754
2377
 
1755
- def parse_fsck_root_line(line, result)
1756
- 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)
1757
2388
 
1758
- result[:root] << Git::FsckObject.new(type: :commit, sha: match[1])
2389
+ result
1759
2390
  end
1760
2391
 
1761
- def parse_fsck_tagged_line(line, result)
1762
- 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?(':')
1763
2406
 
1764
- 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
1765
2413
  end
1766
2414
 
1767
- def parse_diff_path_status(args)
1768
- command_lines('diff', *args).each_with_object({}) do |line, memo|
1769
- status, path = split_status_line(line)
1770
- memo[path] = status
1771
- 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
1772
2428
  end
1773
2429
 
1774
- def build_checkout_positional_args(branch, opts)
1775
- args = []
1776
- if opts[:new_branch] || opts[:b]
1777
- args.push('-b', branch)
1778
- args << opts[:start_point] if opts[:start_point]
1779
- elsif branch
1780
- args << branch
1781
- end
1782
- 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/) }
1783
2442
  end
1784
2443
 
1785
2444
  def build_args(opts, option_map)
1786
2445
  Git::ArgsBuilder.new(opts, option_map).build
1787
2446
  end
1788
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
+
1789
2468
  def initialize_from_base(base_object)
1790
2469
  @git_dir = base_object.repo.to_s
1791
2470
  @git_index_file = base_object.index&.to_s
@@ -1800,15 +2479,6 @@ module Git
1800
2479
  @git_ssh = base_hash.key?(:git_ssh) ? base_hash[:git_ssh] : :use_global_config
1801
2480
  end
1802
2481
 
1803
- def return_base_opts_from_clone(clone_dir, opts)
1804
- base_opts = {}
1805
- base_opts[:repository] = clone_dir if opts[:bare] || opts[:mirror]
1806
- base_opts[:working_directory] = clone_dir unless opts[:bare] || opts[:mirror]
1807
- base_opts[:log] = opts[:log] if opts[:log]
1808
- base_opts[:git_ssh] = opts[:git_ssh] if opts.key?(:git_ssh)
1809
- base_opts
1810
- end
1811
-
1812
2482
  def process_commit_headers(data)
1813
2483
  headers = { 'parent' => [] } # Pre-initialize for multiple parents
1814
2484
  each_cat_file_header(data) do |key, value|
@@ -1821,44 +2491,8 @@ module Git
1821
2491
  headers
1822
2492
  end
1823
2493
 
1824
- def parse_branch_line(line, index, all_lines)
1825
- match_data = match_branch_line(line, index, all_lines)
1826
-
1827
- return nil if match_data[:not_a_branch] || match_data[:detached_ref]
1828
-
1829
- format_branch_data(match_data)
1830
- end
1831
-
1832
- def match_branch_line(line, index, all_lines)
1833
- match_data = line.match(BRANCH_LINE_REGEXP)
1834
- raise Git::UnexpectedResultError, unexpected_branch_line_error(all_lines, line, index) unless match_data
1835
-
1836
- match_data
1837
- end
1838
-
1839
- def format_branch_data(match_data)
1840
- [
1841
- match_data[:refname],
1842
- !match_data[:current].nil?,
1843
- !match_data[:worktree].nil?,
1844
- match_data[:symref]
1845
- ]
1846
- end
1847
-
1848
- def unexpected_branch_line_error(lines, line, index)
1849
- <<~ERROR
1850
- Unexpected line in output from `git branch -a`, line #{index + 1}
1851
-
1852
- Full output:
1853
- #{lines.join("\n ")}
1854
-
1855
- Line #{index + 1}:
1856
- "#{line}"
1857
- ERROR
1858
- end
1859
-
1860
2494
  def get_branch_state(branch_name)
1861
- command('rev-parse', '--verify', '--quiet', branch_name)
2495
+ Git::Commands::RevParse.new(self).call(branch_name, verify: true, quiet: true)
1862
2496
  :active
1863
2497
  rescue Git::FailedError => e
1864
2498
  # An exit status of 1 with empty stderr from `rev-parse --verify`
@@ -1868,23 +2502,14 @@ module Git
1868
2502
  :unborn
1869
2503
  end
1870
2504
 
1871
- def execute_grep_command(args)
1872
- command_lines('grep', *args)
1873
- rescue Git::FailedError => e
1874
- # `git grep` returns 1 when no lines are selected.
1875
- raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
1876
-
1877
- [] # 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
1878
2509
  end
1879
2510
 
1880
- def parse_grep_output(lines)
1881
- lines.each_with_object(Hash.new { |h, k| h[k] = [] }) do |line, hsh|
1882
- match = line.match(/\A(.*?):(\d+):(.*)/)
1883
- next unless match
1884
-
1885
- _full, filename, line_num, text = match.to_a
1886
- hsh[filename] << [line_num.to_i, text]
1887
- end
2511
+ def parse_grep_output(output)
2512
+ Git::Parsers::Grep.parse(output)
1888
2513
  end
1889
2514
 
1890
2515
  def parse_diff_stats_output(lines)
@@ -1909,6 +2534,18 @@ module Git
1909
2534
  parts
1910
2535
  end
1911
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
+
1912
2549
  def build_final_stats_hash(file_stats)
1913
2550
  {
1914
2551
  total: build_total_stats(file_stats),
@@ -1954,45 +2591,48 @@ module Git
1954
2591
  [type, name, value]
1955
2592
  end
1956
2593
 
1957
- def stash_log_lines
1958
- path = File.join(@git_dir, 'logs/refs/stash')
1959
- return [] unless File.exist?(path)
1960
-
1961
- File.readlines(path, chomp: true)
1962
- end
1963
-
1964
- def parse_stash_log_line(line, index)
1965
- 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
1966
2606
  match_data = full_message.match(/^[^:]+:(.*)$/)
1967
2607
  message = match_data ? match_data[1] : full_message
1968
2608
 
1969
2609
  [index, message.strip]
1970
2610
  end
1971
2611
 
1972
- # 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
1973
2618
  #
1974
2619
  # @param path [String] the path to the file in the index
1975
2620
  #
1976
- # @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)
1977
2622
  #
1978
- # @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
1979
2624
  #
1980
- # @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
1981
2630
  #
1982
2631
  def write_staged_content(path, stage, out_io)
1983
- command('show', ":#{stage}:#{path}", out: out_io)
2632
+ Git::Commands::Show.new(self).call(":#{stage}:#{path}", out: out_io)
1984
2633
  out_io
1985
2634
  end
1986
2635
 
1987
- def validate_tag_options!(opts)
1988
- is_annotated = opts[:a] || opts[:annotate]
1989
- has_message = opts[:m] || opts[:message]
1990
-
1991
- return unless is_annotated && !has_message
1992
-
1993
- raise ArgumentError, 'Cannot create an annotated tag without a message.'
1994
- end
1995
-
1996
2636
  def normalize_push_args(remote, branch, opts)
1997
2637
  if branch.is_a?(Hash)
1998
2638
  opts = branch
@@ -2008,15 +2648,22 @@ module Git
2008
2648
  [remote, branch, opts]
2009
2649
  end
2010
2650
 
2011
- def build_push_args(remote, branch, opts)
2012
- # Build the simple flags using the ArgsBuilder
2013
- 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
2014
2664
 
2015
- # Manually handle the flag with external dependencies and positional args
2016
- args << '--all' if opts[:all] && remote
2017
- args << remote if remote
2018
- args << branch if branch
2019
- args
2665
+ def push_tags(remote, opts)
2666
+ Git::Commands::Push.new(self).call(*[remote].compact, **opts)
2020
2667
  end
2021
2668
 
2022
2669
  def temp_file_name
@@ -2038,16 +2685,6 @@ module Git
2038
2685
  Zlib::GzipWriter.open(file) { |gz| gz.write(file_content) }
2039
2686
  end
2040
2687
 
2041
- def command_lines(cmd, *opts, chdir: nil)
2042
- cmd_op = command(cmd, *opts, chdir: chdir)
2043
- op = if cmd_op.encoding.name == 'UTF-8'
2044
- cmd_op
2045
- else
2046
- cmd_op.encode('UTF-8', 'binary', invalid: :replace, undef: :replace)
2047
- end
2048
- op.split("\n")
2049
- end
2050
-
2051
2688
  # Returns a hash of environment variable overrides for git commands
2052
2689
  #
2053
2690
  # This method builds a hash of environment variables that control git's behavior,
@@ -2088,6 +2725,7 @@ module Git
2088
2725
  'GIT_WORK_TREE' => @git_work_dir,
2089
2726
  'GIT_INDEX_FILE' => @git_index_file,
2090
2727
  'GIT_SSH' => resolved_git_ssh,
2728
+ 'GIT_EDITOR' => 'true', # Use a no-op editor so Git skips interactive editing but continues
2091
2729
  'LC_ALL' => 'en_US.UTF-8'
2092
2730
  }.merge(additional_overrides)
2093
2731
  end
@@ -2115,154 +2753,85 @@ module Git
2115
2753
  end
2116
2754
  end
2117
2755
 
2118
- def command_line
2119
- @command_line ||=
2120
- Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2121
- end
2122
-
2123
- # Returns a command line instance without GIT_INDEX_FILE for worktree commands
2124
- #
2125
- # Git worktrees manage their own index files and setting GIT_INDEX_FILE
2126
- # causes corruption of both the main worktree and new worktree indexes.
2127
- #
2128
- # @return [Git::CommandLine]
2129
- # @api private
2130
- #
2131
- def worktree_command_line
2132
- @worktree_command_line ||=
2133
- Git::CommandLine.new(env_overrides('GIT_INDEX_FILE' => nil), Git::Base.config.binary_path, global_opts,
2134
- @logger)
2135
- end
2136
-
2137
- # @overload worktree_command(*args, **options_hash)
2138
- # Runs a git worktree command and returns the output
2756
+ # Returns the {Git::CommandLine::Capturing} instance used for capturing execution
2139
2757
  #
2140
- # This method is similar to #command but uses a command line instance
2141
- # 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.
2142
2761
  #
2143
- # @param args [Array<String>] the command arguments
2144
- # @param options_hash [Hash] the options to pass to the command
2145
- #
2146
- # @return [String] the command's stdout
2762
+ # @return [Git::CommandLine::Capturing]
2147
2763
  #
2148
- # @see #command
2149
- #
2150
- # @api private
2764
+ # @see Git::CommandLine::Capturing#run
2151
2765
  #
2152
- def worktree_command(*, **options_hash)
2153
- options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2154
- options_hash[:timeout] ||= Git.config.timeout
2155
-
2156
- extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2157
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2158
-
2159
- result = worktree_command_line.run(*, **options_hash)
2160
- 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)
2161
2769
  end
2162
2770
 
2163
- # Runs a git command and returns the output
2164
- #
2165
- # Additional args are passed to the command line. They should exclude the 'git'
2166
- # command itself and global options. Remember to splat the the arguments if given
2167
- # as an array.
2168
- #
2169
- # For example, to run `git log --pretty=oneline`, you would create the array
2170
- # `args = ['log', '--pretty=oneline']` and call `command(*args)`.
2171
- #
2172
- # @param options_hash [Hash] the options to pass to the command
2173
- # @option options_hash [IO, String, #write, nil] :out the destination for captured stdout
2174
- # @option options_hash [IO, String, #write, nil] :err the destination for captured stderr
2175
- # @option options_hash [Boolean] :normalize true to normalize the output encoding to UTF-8
2176
- # @option options_hash [Boolean] :chomp true to remove trailing newlines from the output
2177
- # @option options_hash [Boolean] :merge true to merge stdout and stderr into a single output
2178
- # @option options_hash [String, nil] :chdir the directory to run the command in
2179
- # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command to complete
2180
- #
2181
- # If timeout is nil, the global timeout from {Git::Config} is used.
2182
- #
2183
- # If timeout is zero, the timeout will not be enforced.
2184
- #
2185
- # If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised.
2186
- #
2187
- # If the command does not respond to SIGKILL, it will hang this method.
2188
- #
2189
- # @see Git::CommandLine#run
2190
- #
2191
- # @return [String] the command's stdout (or merged stdout and stderr if `merge`
2192
- # is true)
2193
- #
2194
- # @raise [ArgumentError] if an unknown option is passed
2771
+ # Returns the {Git::CommandLine::Streaming} instance used for streaming execution
2195
2772
  #
2196
- # @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.
2197
2776
  #
2198
- # @raise [Git::SignaledError] if the command was signaled
2199
- #
2200
- # @raise [Git::TimeoutError] if the command times out
2201
- #
2202
- # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2203
- #
2204
- # The exception's `result` attribute is a {Git::CommandLineResult} which will
2205
- # contain the result of the command including the exit status, stdout, and
2206
- # stderr.
2777
+ # @return [Git::CommandLine::Streaming]
2207
2778
  #
2208
- # @api private
2779
+ # @see Git::CommandLine::Streaming#run
2209
2780
  #
2210
- def command(*, **options_hash)
2211
- options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2212
- options_hash[:timeout] ||= Git.config.timeout
2213
-
2214
- extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2215
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2216
-
2217
- result = command_line.run(*, **options_hash)
2218
- 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)
2219
2784
  end
2220
2785
 
2221
- # Takes the diff command line output (as Array) and parse it into a Hash
2786
+ # Validates the :count option for log commands.
2222
2787
  #
2223
- # @param [String] diff_command the diff commadn to be used
2224
- # @param [Array] opts the diff options to be used
2225
- # @return [Hash] the diff as Hash
2226
- def diff_as_hash(diff_command, opts = [])
2227
- # update index before diffing to avoid spurious diffs
2228
- command('status')
2229
- command_lines(diff_command, *opts).each_with_object({}) do |line, memo|
2230
- info, file = split_status_line(line)
2231
- 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)
2232
2790
 
2233
- memo[file] = {
2234
- mode_index: mode_dest, mode_repo: mode_src.to_s[1, 7],
2235
- path: file, sha_repo: sha_src, sha_index: sha_dest,
2236
- type: type
2237
- }
2238
- end
2791
+ raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
2239
2792
  end
2240
2793
 
2241
- # 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
2242
2795
  #
2243
- # @param [Hash] opts the given options
2244
- # @return [Array] the set of common options that the log command will use
2245
- def log_common_options(opts)
2246
- if opts[:count] && !opts[:count].is_a?(Integer)
2247
- 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
+ []
2248
2805
  end
2249
-
2250
- build_args(opts, LOG_OPTION_MAP)
2251
2806
  end
2252
2807
 
2253
- # Retrurns an array holding path options for the log commands
2808
+ # Builds the common keyword options for Git::Commands::Log from opts
2254
2809
  #
2255
- # @param [Hash] opts the given options
2256
- # @return [Array] the set of path options that the log command will use
2257
- def log_path_options(opts)
2258
- arr_opts = []
2259
-
2260
- arr_opts << opts[:object] if opts[:object].is_a? String
2261
- if opts[:path_limiter]
2262
- arr_opts << '--'
2263
- 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"))
2264
2834
  end
2265
- arr_opts
2266
2835
  end
2267
2836
 
2268
2837
  def log_or_empty_on_unborn
@@ -2273,5 +2842,14 @@ module Git
2273
2842
 
2274
2843
  []
2275
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
2276
2854
  end
2277
2855
  end