git 4.3.1 → 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 +9 -1
  42. data/.rubocop_todo.yml +23 -5
  43. data/.yardopts +1 -0
  44. data/CHANGELOG.md +0 -27
  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 +298 -8
  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 +1613 -1024
  229. data/lib/git/log.rb +17 -4
  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 +216 -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 +113 -17
  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
- 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,15 +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
- full_log = command_lines('log', *args)
364
- process_commit_log_data(full_log)
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)
365
294
  end
366
295
 
367
296
  # Verify and resolve a Git revision to its full SHA
@@ -380,12 +309,9 @@ module Git
380
309
  # @return [String] the full commit hash
381
310
  #
382
311
  # @raise [Git::FailedError] if the revision cannot be resolved
383
- # @raise [ArgumentError] if the revision is a string starting with a hyphen
384
312
  #
385
313
  def rev_parse(revision)
386
- assert_args_are_not_options('rev', revision)
387
-
388
- command('rev-parse', '--revs-only', '--end-of-options', revision, '--')
314
+ Git::Commands::RevParse.new(self).call(revision, '--', revs_only: true).stdout
389
315
  end
390
316
 
391
317
  # For backwards compatibility with the old method name
@@ -402,42 +328,69 @@ module Git
402
328
  def name_rev(commit_ish)
403
329
  assert_args_are_not_options('commit_ish', commit_ish)
404
330
 
405
- command('name-rev', commit_ish).split[1]
331
+ Git::Commands::NameRev.new(self).call(commit_ish).stdout.split[1]
406
332
  end
407
333
 
408
334
  alias namerev name_rev
409
335
 
410
- # 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.
411
341
  #
412
342
  # @see https://git-scm.com/docs/git-cat-file git-cat-file
413
343
  #
414
- # @example Get the contents of a file without a block
415
- # 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.
416
346
  #
417
- # @example Get the contents of a file with a block
418
- # 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.)
419
348
  #
420
- # @param object [String] the object whose contents to return
349
+ # @return [String] the raw content of the object
421
350
  #
422
- # @return [String] the object contents
351
+ # @raise [ArgumentError] if `object` starts with a hyphen
423
352
  #
424
- # @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) }
425
381
  #
426
382
  def cat_file_contents(object)
427
383
  assert_args_are_not_options('object', object)
428
384
 
429
- if block_given?
430
- Tempfile.create do |file|
431
- # If a block is given, write the output from the process to a temporary
432
- # file and then yield the file to the block
433
- #
434
- command('cat-file', '-p', object, out: file, err: file)
435
- file.rewind
436
- yield file
437
- end
438
- else
439
- # If a block is not given, return the file contents as a string
440
- 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
441
394
  end
442
395
  end
443
396
 
@@ -456,7 +409,7 @@ module Git
456
409
  def cat_file_type(object)
457
410
  assert_args_are_not_options('object', object)
458
411
 
459
- command('cat-file', '-t', object)
412
+ cat_file_object_meta(object)[:type]
460
413
  end
461
414
 
462
415
  alias object_type cat_file_type
@@ -465,16 +418,16 @@ module Git
465
418
  #
466
419
  # @see https://git-scm.com/docs/git-cat-file git-cat-file
467
420
  #
468
- # @param object [String] the object to get the type
421
+ # @param object [String] the object to get the size of
469
422
  #
470
- # @return [String] the object type
423
+ # @return [Integer] the object size in bytes
471
424
  #
472
425
  # @raise [ArgumentError] if object is a string starting with a hyphen
473
426
  #
474
427
  def cat_file_size(object)
475
428
  assert_args_are_not_options('object', object)
476
429
 
477
- command('cat-file', '-s', object).to_i
430
+ cat_file_object_meta(object)[:size]
478
431
  end
479
432
 
480
433
  alias object_size cat_file_size
@@ -500,7 +453,7 @@ module Git
500
453
  def cat_file_commit(object)
501
454
  assert_args_are_not_options('object', object)
502
455
 
503
- cdata = command_lines('cat-file', 'commit', object)
456
+ cdata = Git::Commands::CatFile::Raw.new(self).call('commit', object).stdout.split("\n")
504
457
  process_commit_data(cdata, object)
505
458
  end
506
459
 
@@ -517,6 +470,26 @@ module Git
517
470
 
518
471
  CAT_FILE_HEADER_LINE = /\A(?<key>\w+) (?<value>.*)\z/
519
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
+ #
520
493
  def each_cat_file_header(data)
521
494
  while (match = CAT_FILE_HEADER_LINE.match(data.shift))
522
495
  key = match[:key]
@@ -569,12 +542,39 @@ module Git
569
542
  def cat_file_tag(object)
570
543
  assert_args_are_not_options('object', object)
571
544
 
572
- tdata = command_lines('cat-file', 'tag', object)
545
+ tdata = Git::Commands::CatFile::Raw.new(self).call('tag', object).stdout.split("\n")
573
546
  process_tag_data(tdata, object)
574
547
  end
575
548
 
576
549
  alias tag_data cat_file_tag
577
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
+
578
578
  def process_tag_data(data, name)
579
579
  hsh = { 'name' => name }
580
580
 
@@ -649,32 +649,48 @@ module Git
649
649
  end
650
650
  private_constant :RawLogParser
651
651
 
652
- LS_TREE_OPTION_MAP = [
653
- { keys: [:recursive], flag: '-r', type: :boolean }
654
- ].freeze
652
+ # Allowed option keys for {#ls_tree}
653
+ LS_TREE_ALLOWED_OPTS = %i[recursive path].freeze
655
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
+ #
656
665
  def ls_tree(sha, opts = {})
657
- data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
658
- args = build_args(opts, LS_TREE_OPTION_MAP)
659
-
660
- args.unshift(sha)
661
- 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
662
674
 
663
- 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|
664
678
  (info, filenm) = split_status_line(line)
665
- (mode, type, sha) = info.split
666
- data[type][filenm] = { mode: mode, sha: sha }
679
+ (mode, type, entry_sha) = info.split
680
+ data[type][filenm] = { mode: mode, sha: entry_sha }
667
681
  end
668
-
669
682
  data
670
683
  end
684
+ private :parse_ls_tree_output
671
685
 
672
- def mv(file1, file2)
673
- 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
674
690
  end
675
691
 
676
692
  def full_tree(sha)
677
- command_lines('ls-tree', '-r', sha)
693
+ Git::Commands::LsTree.new(self).call(sha, r: true).stdout.split("\n")
678
694
  end
679
695
 
680
696
  def tree_depth(sha)
@@ -682,38 +698,12 @@ module Git
682
698
  end
683
699
 
684
700
  def change_head_branch(branch_name)
685
- command('symbolic-ref', 'HEAD', "refs/heads/#{branch_name}")
701
+ Git::Commands::SymbolicRef::Update.new(self).call('HEAD', "refs/heads/#{branch_name}")
686
702
  end
687
703
 
688
- BRANCH_LINE_REGEXP = /
689
- ^
690
- # Prefix indicates if this branch is checked out. The prefix is one of:
691
- (?:
692
- (?<current>\*[[:blank:]]) | # Current branch (checked out in the current worktree)
693
- (?<worktree>\+[[:blank:]]) | # Branch checked out in a different worktree
694
- [[:blank:]]{2} # Branch not checked out
695
- )
696
-
697
- # The branch's full refname
698
- (?:
699
- (?<not_a_branch>\(not[[:blank:]]a[[:blank:]]branch\)) |
700
- (?:\(HEAD[[:blank:]]detached[[:blank:]]at[[:blank:]](?<detached_ref>[^)]+)\)) |
701
- (?<refname>[^[[:blank:]]]+)
702
- )
703
-
704
- # Optional symref
705
- # If this ref is a symbolic reference, this is the ref referenced
706
- (?:
707
- [[:blank:]]->[[:blank:]](?<symref>.*)
708
- )?
709
- $
710
- /x
711
-
712
704
  def branches_all
713
- lines = command_lines('branch', '-a')
714
- lines.each_with_index.filter_map do |line, index|
715
- parse_branch_line(line, index, lines)
716
- 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)
717
707
  end
718
708
 
719
709
  def worktrees_all
@@ -728,7 +718,7 @@ module Git
728
718
  # HEAD b8c63206f8d10f57892060375a86ae911fad356e
729
719
  # detached
730
720
  #
731
- command_lines('worktree', 'list', '--porcelain').each do |w|
721
+ Git::Commands::Worktree::List.new(self).call(porcelain: true).stdout.split("\n").each do |w|
732
722
  s = w.split
733
723
  directory = s[1] if s[0] == 'worktree'
734
724
  arr << [directory, s[1]] if s[0] == 'HEAD'
@@ -737,17 +727,19 @@ module Git
737
727
  end
738
728
 
739
729
  def worktree_add(dir, commitish = nil)
740
- return worktree_command('worktree', 'add', dir, commitish) unless commitish.nil?
741
-
742
- 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
743
735
  end
744
736
 
745
737
  def worktree_remove(dir)
746
- worktree_command('worktree', 'remove', dir)
738
+ Git::Commands::Worktree::Remove.new(self).call(dir).stdout
747
739
  end
748
740
 
749
741
  def worktree_prune
750
- worktree_command('worktree', 'prune')
742
+ Git::Commands::Worktree::Prune.new(self).call.stdout
751
743
  end
752
744
 
753
745
  def list_files(ref_dir)
@@ -784,7 +776,7 @@ module Git
784
776
  # @return [HeadState] the state and name of the current branch
785
777
  #
786
778
  def current_branch_state
787
- branch_name = command('branch', '--show-current')
779
+ branch_name = Git::Commands::Branch::ShowCurrent.new(self).call.stdout
788
780
  return HeadState.new(:detached, 'HEAD') if branch_name.empty?
789
781
 
790
782
  state = get_branch_state(branch_name)
@@ -792,39 +784,36 @@ module Git
792
784
  end
793
785
 
794
786
  def branch_current
795
- branch_name = command('branch', '--show-current')
796
- 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
797
790
  end
798
791
 
799
792
  def branch_contains(commit, branch_name = '')
800
- 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
801
796
  end
802
797
 
803
- GREP_OPTION_MAP = [
804
- { keys: [:ignore_case], flag: '-i', type: :boolean },
805
- { keys: [:invert_match], flag: '-v', type: :boolean },
806
- { keys: [:extended_regexp], flag: '-E', type: :boolean },
807
- # For validation only, as these are handled manually
808
- { keys: [:object], type: :validate_only },
809
- { keys: [:path_limiter], type: :validate_only }
810
- ].freeze
798
+ GREP_ALLOWED_OPTS = %i[ignore_case i invert_match v extended_regexp E object path_limiter].freeze
811
799
 
812
- # returns hash
813
- # [tree-ish] = [[line_no, match], [line_no, match2]]
814
- # [tree-ish] = [[line_no, match], [line_no, match2]]
815
- def grep(string, opts = {})
816
- opts[:object] ||= 'HEAD'
817
- ArgsBuilder.validate!(opts, GREP_OPTION_MAP)
800
+ def grep(pattern, opts = {})
801
+ assert_valid_opts(opts, GREP_ALLOWED_OPTS)
818
802
 
819
- boolean_flags = build_args(opts, GREP_OPTION_MAP)
820
- 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
821
809
 
822
- if (limiter = opts[:path_limiter])
823
- args.push('--', *Array(limiter))
824
- 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
825
815
 
826
- lines = execute_grep_command(args)
827
- parse_grep_output(lines)
816
+ parse_grep_output(result.stdout)
828
817
  end
829
818
 
830
819
  # Validate that the given arguments cannot be mistaken for a command-line option
@@ -877,7 +866,24 @@ module Git
877
866
  raise ArgumentError, "Invalid #{arg_name}: must be a String, Pathname, or Array of Strings/Pathnames"
878
867
  end
879
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
+
880
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
+ #
881
887
  def handle_deprecated_path_option(opts)
882
888
  if opts.key?(:path_limiter)
883
889
  opts[:path_limiter]
@@ -889,74 +895,131 @@ module Git
889
895
  end
890
896
  end
891
897
 
892
- DIFF_FULL_OPTION_MAP = [
893
- { type: :static, flag: '-p' },
894
- { keys: [:path_limiter], type: :validate_only }
895
- ].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
896
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
+ #
897
930
  def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
898
- assert_args_are_not_options('commit or commit range', obj1, obj2)
899
- ArgsBuilder.validate!(opts, DIFF_FULL_OPTION_MAP)
900
-
901
- args = build_args(opts, DIFF_FULL_OPTION_MAP)
902
- args.push(obj1, obj2).compact!
903
-
904
- if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
905
- args.push('--', *pathspecs)
906
- end
907
-
908
- 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)
909
940
  end
910
941
 
911
- DIFF_STATS_OPTION_MAP = [
912
- { type: :static, flag: '--numstat' },
913
- { keys: [:path_limiter], type: :validate_only }
914
- ].freeze
915
-
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
+ #
916
962
  def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
917
- assert_args_are_not_options('commit or commit range', obj1, obj2)
918
- ArgsBuilder.validate!(opts, DIFF_STATS_OPTION_MAP)
919
-
920
- args = build_args(opts, DIFF_STATS_OPTION_MAP)
921
- args.push(obj1, obj2).compact!
922
-
923
- if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
924
- args.push('--', *pathspecs)
925
- end
926
-
927
- 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)
928
972
  parse_diff_stats_output(output_lines)
929
973
  end
930
974
 
931
- DIFF_PATH_STATUS_OPTION_MAP = [
932
- { type: :static, flag: '--name-status' },
933
- { keys: [:path_limiter], type: :validate_only },
934
- { keys: [:path], type: :validate_only }
935
- ].freeze
936
-
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
+ #
937
999
  def diff_path_status(reference1 = nil, reference2 = nil, opts = {})
938
- assert_args_are_not_options('commit or commit range', reference1, reference2)
939
- ArgsBuilder.validate!(opts, DIFF_PATH_STATUS_OPTION_MAP)
940
-
941
- args = build_args(opts, DIFF_PATH_STATUS_OPTION_MAP)
942
- args.push(reference1, reference2).compact!
1000
+ assert_valid_opts(opts, DIFF_PATH_STATUS_ALLOWED_OPTS)
943
1001
 
944
1002
  path_limiter = handle_deprecated_path_option(opts)
945
- if (pathspecs = normalize_pathspecs(path_limiter, 'path limiter'))
946
- args.push('--', *pathspecs)
947
- end
948
-
949
- 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)
950
1011
  end
951
1012
 
952
1013
  # compares the index and the working directory
953
1014
  def diff_files
954
- diff_as_hash('diff-files')
1015
+ Git::Commands::Status.new(self).call
1016
+ parse_raw_diff_output(Git::Commands::DiffFiles.new(self).call.stdout)
955
1017
  end
956
1018
 
957
1019
  # compares the index and the repository
958
1020
  def diff_index(treeish)
959
- 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)
960
1023
  end
961
1024
 
962
1025
  # List all files that are in the index
@@ -974,7 +1037,7 @@ module Git
974
1037
  def ls_files(location = nil)
975
1038
  location ||= '.'
976
1039
  {}.tap do |files|
977
- 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|
978
1041
  (info, file) = split_status_line(line)
979
1042
  (mode, sha, stage) = info.split
980
1043
  files[file] = {
@@ -1007,28 +1070,22 @@ module Git
1007
1070
  end
1008
1071
  end
1009
1072
 
1010
- LS_REMOTE_OPTION_MAP = [
1011
- { keys: [:refs], flag: '--refs', type: :boolean }
1012
- ].freeze
1013
-
1014
1073
  def ls_remote(location = nil, opts = {})
1015
- ArgsBuilder.validate!(opts, LS_REMOTE_OPTION_MAP)
1016
-
1017
- flags = build_args(opts, LS_REMOTE_OPTION_MAP)
1018
- positional_arg = location || '.'
1019
-
1020
- 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")
1021
1076
  parse_ls_remote_output(output_lines)
1022
1077
  end
1023
1078
 
1024
1079
  def ignored_files
1025
- 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) }
1026
1083
  end
1027
1084
 
1028
1085
  def untracked_files
1029
- command_lines('ls-files', '--others', '--exclude-standard', chdir: @git_work_dir).map do |f|
1030
- unescape_quoted_path(f)
1031
- 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) }
1032
1089
  end
1033
1090
 
1034
1091
  def config_remote(name)
@@ -1040,19 +1097,25 @@ module Git
1040
1097
  end
1041
1098
 
1042
1099
  def config_get(name)
1043
- 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
1044
1104
  end
1045
1105
 
1046
1106
  def global_config_get(name)
1047
- 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
1048
1111
  end
1049
1112
 
1050
1113
  def config_list
1051
- parse_config_list command_lines('config', '--list', chdir: @git_dir)
1114
+ parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call.stdout.split("\n")
1052
1115
  end
1053
1116
 
1054
1117
  def global_config_list
1055
- parse_config_list command_lines('config', '--global', '--list')
1118
+ parse_config_list Git::Commands::ConfigOptionSyntax::List.new(self).call(global: true).stdout.split("\n")
1056
1119
  end
1057
1120
 
1058
1121
  def parse_config_list(lines)
@@ -1065,7 +1128,7 @@ module Git
1065
1128
  end
1066
1129
 
1067
1130
  def parse_config(file)
1068
- 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")
1069
1132
  end
1070
1133
 
1071
1134
  # Shows objects
@@ -1074,34 +1137,23 @@ module Git
1074
1137
  # @param [String|NilClass] path the path of the file to be shown
1075
1138
  # @return [String] the object information
1076
1139
  def show(objectish = nil, path = nil)
1077
- arr_opts = []
1078
-
1079
- arr_opts << (path ? "#{objectish}:#{path}" : objectish)
1080
-
1081
- command('show', *arr_opts.compact, chomp: false)
1140
+ object = path ? "#{objectish}:#{path}" : objectish
1141
+ Git::Commands::Show.new(self).call(*[object].compact).stdout
1082
1142
  end
1083
1143
 
1084
1144
  ## WRITE COMMANDS ##
1085
1145
 
1086
- CONFIG_SET_OPTION_MAP = [
1087
- { keys: [:file], flag: '--file', type: :valued_space }
1088
- ].freeze
1146
+ CONFIG_SET_ALLOWED_OPTS = %i[file].freeze
1089
1147
 
1090
1148
  def config_set(name, value, options = {})
1091
- ArgsBuilder.validate!(options, CONFIG_SET_OPTION_MAP)
1092
- flags = build_args(options, CONFIG_SET_OPTION_MAP)
1093
- 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))
1094
1151
  end
1095
1152
 
1096
1153
  def global_config_set(name, value)
1097
- command('config', '--global', name, value)
1154
+ Git::Commands::ConfigOptionSyntax::Set.new(self).call(name, value, global: true)
1098
1155
  end
1099
1156
 
1100
- ADD_OPTION_MAP = [
1101
- { keys: [:all], flag: '--all', type: :boolean },
1102
- { keys: [:force], flag: '--force', type: :boolean }
1103
- ].freeze
1104
-
1105
1157
  # Update the index from the current worktree to prepare the for the next commit
1106
1158
  #
1107
1159
  # @example
@@ -1112,31 +1164,28 @@ module Git
1112
1164
  # @param [String, Array<String>] paths files to be added to the repository (relative to the worktree root)
1113
1165
  # @param [Hash] options
1114
1166
  #
1115
- # @option options [Boolean] :all Add, modify, and remove index entries to match the worktree
1116
- # @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)
1117
1171
  #
1118
1172
  def add(paths = '.', options = {})
1119
- args = build_args(options, ADD_OPTION_MAP)
1120
-
1121
- args << '--'
1122
- args.concat(Array(paths))
1123
-
1124
- command('add', *args)
1173
+ Git::Commands::Add.new(self).call(*Array(paths), **options).stdout
1125
1174
  end
1126
1175
 
1127
- RM_OPTION_MAP = [
1128
- { type: :static, flag: '-f' },
1129
- { keys: [:recursive], flag: '-r', type: :boolean },
1130
- { keys: [:cached], flag: '--cached', type: :boolean }
1131
- ].freeze
1132
-
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
+ #
1133
1187
  def rm(path = '.', opts = {})
1134
- args = build_args(opts, RM_OPTION_MAP)
1135
-
1136
- args << '--'
1137
- args.concat(Array(path))
1138
-
1139
- command('rm', *args)
1188
+ Git::Commands::Rm.new(self).call(*Array(path), **opts).stdout
1140
1189
  end
1141
1190
 
1142
1191
  # Returns true if the repository is empty (meaning it has no commits)
@@ -1144,7 +1193,7 @@ module Git
1144
1193
  # @return [Boolean]
1145
1194
  #
1146
1195
  def empty?
1147
- command('rev-parse', '--verify', 'HEAD')
1196
+ Git::Commands::RevParse.new(self).call('HEAD', verify: true)
1148
1197
  false
1149
1198
  rescue Git::FailedError => e
1150
1199
  raise unless e.result.status.exitstatus == 128 &&
@@ -1153,27 +1202,6 @@ module Git
1153
1202
  true
1154
1203
  end
1155
1204
 
1156
- COMMIT_OPTION_MAP = [
1157
- { keys: %i[add_all all], flag: '--all', type: :boolean },
1158
- { keys: [:allow_empty], flag: '--allow-empty', type: :boolean },
1159
- { keys: [:no_verify], flag: '--no-verify', type: :boolean },
1160
- { keys: [:allow_empty_message], flag: '--allow-empty-message', type: :boolean },
1161
- { keys: [:author], flag: '--author', type: :valued_equals },
1162
- { keys: [:message], flag: '--message', type: :valued_equals },
1163
- { keys: [:no_gpg_sign], flag: '--no-gpg-sign', type: :boolean },
1164
- { keys: [:date], flag: '--date', type: :valued_equals, validator: ->(v) { v.is_a?(String) } },
1165
- { keys: [:amend], type: :custom, builder: ->(value) { ['--amend', '--no-edit'] if value } },
1166
- {
1167
- keys: [:gpg_sign],
1168
- type: :custom,
1169
- builder: lambda { |value|
1170
- if value
1171
- value == true ? '--gpg-sign' : "--gpg-sign=#{value}"
1172
- end
1173
- }
1174
- }
1175
- ].freeze
1176
-
1177
1205
  # Takes the commit message with the options and executes the commit command
1178
1206
  #
1179
1207
  # accepts options:
@@ -1189,176 +1217,284 @@ module Git
1189
1217
  #
1190
1218
  # @param [String] message the commit message to be used
1191
1219
  # @param [Hash] opts the commit options to be used
1192
-
1220
+ #
1193
1221
  def commit(message, opts = {})
1194
- opts[:message] = message if message # Handle message arg for backward compatibility
1195
-
1196
- # Perform cross-option validation before building args
1197
- raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign' if opts[:gpg_sign] && opts[:no_gpg_sign]
1198
-
1199
- ArgsBuilder.validate!(opts, COMMIT_OPTION_MAP)
1200
-
1201
- args = build_args(opts, COMMIT_OPTION_MAP)
1202
- 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
1203
1225
  end
1204
- RESET_OPTION_MAP = [
1205
- { keys: [:hard], flag: '--hard', type: :boolean }
1206
- ].freeze
1207
1226
 
1208
- def reset(commit, opts = {})
1209
- args = build_args(opts, RESET_OPTION_MAP)
1210
- args << commit if commit
1211
- 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
1212
1231
  end
1213
1232
 
1214
- CLEAN_OPTION_MAP = [
1215
- { keys: [:force], flag: '--force', type: :boolean },
1216
- { keys: [:ff], flag: '-ff', type: :boolean },
1217
- { keys: [:d], flag: '-d', type: :boolean },
1218
- { keys: [:x], flag: '-x', type: :boolean }
1219
- ].freeze
1220
-
1233
+ # @return [String] the command output
1234
+ #
1221
1235
  def clean(opts = {})
1222
- args = build_args(opts, CLEAN_OPTION_MAP)
1223
- command('clean', *args)
1236
+ opts = migrate_clean_legacy_options(opts)
1237
+ Git::Commands::Clean.new(self).call(**opts).stdout
1224
1238
  end
1225
1239
 
1226
- REVERT_OPTION_MAP = [
1227
- { keys: [:no_edit], flag: '--no-edit', type: :boolean }
1228
- ].freeze
1240
+ REVERT_ALLOWED_OPTS = %i[no_edit].freeze
1229
1241
 
1230
1242
  def revert(commitish, opts = {})
1231
- # Forcing --no-edit as default since it's not an interactive session.
1243
+ assert_valid_opts(opts, REVERT_ALLOWED_OPTS)
1232
1244
  opts = { no_edit: true }.merge(opts)
1233
-
1234
- args = build_args(opts, REVERT_OPTION_MAP)
1235
- args << commitish
1236
-
1237
- command('revert', *args)
1245
+ Git::Commands::Revert::Start.new(self).call(commitish, **opts).stdout
1238
1246
  end
1239
1247
 
1240
1248
  def apply(patch_file)
1241
- arr_opts = []
1242
- arr_opts << '--' << patch_file if patch_file
1243
- command('apply', *arr_opts)
1249
+ Git::Commands::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1244
1250
  end
1245
1251
 
1246
1252
  def apply_mail(patch_file)
1247
- arr_opts = []
1248
- arr_opts << '--' << patch_file if patch_file
1249
- command('am', *arr_opts)
1253
+ Git::Commands::Am::Apply.new(self).call(*[patch_file].compact, chdir: @git_work_dir).stdout
1250
1254
  end
1251
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
+ #
1252
1271
  def stashes_all
1253
- stash_log_lines.each_with_index.map do |line, index|
1254
- parse_stash_log_line(line, index)
1255
- 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) }
1256
1275
  end
1257
1276
 
1258
- def stash_save(message)
1259
- output = command('stash', 'save', message)
1260
- 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')
1261
1294
  end
1262
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
+ #
1263
1312
  def stash_apply(id = nil)
1264
- if id
1265
- command('stash', 'apply', id)
1266
- else
1267
- command('stash', 'apply')
1268
- end
1313
+ result = Git::Commands::Stash::Apply.new(self).call(id)
1314
+ result.stdout
1269
1315
  end
1270
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
+ #
1271
1328
  def stash_clear
1272
- command('stash', 'clear')
1273
- end
1274
-
1275
- def stash_list
1276
- command('stash', 'list')
1329
+ result = Git::Commands::Stash::Clear.new(self).call
1330
+ result.stdout
1277
1331
  end
1278
1332
 
1279
- def branch_new(branch)
1280
- command('branch', branch)
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")
1281
1350
  end
1282
1351
 
1283
- def branch_delete(branch)
1284
- command('branch', '-D', 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
1285
1363
  end
1286
1364
 
1287
- CHECKOUT_OPTION_MAP = [
1288
- { keys: %i[force f], flag: '--force', type: :boolean },
1289
- { keys: %i[new_branch b], type: :validate_only },
1290
- { keys: [:start_point], type: :validate_only }
1291
- ].freeze
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)
1379
+
1380
+ raise Git::Error, result.stderr.strip unless result.status.success?
1381
+
1382
+ result.stdout.strip
1383
+ end
1292
1384
 
1293
1385
  # Runs checkout command to checkout or create branch
1294
1386
  #
1295
1387
  # accepts options:
1296
- # :new_branch
1297
- # :force
1298
- # :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
1299
1395
  #
1300
- # @param [String] branch
1301
- # @param [Hash] opts
1302
1396
  def checkout(branch = nil, opts = {})
1303
1397
  if branch.is_a?(Hash) && opts.empty?
1304
1398
  opts = branch
1305
1399
  branch = nil
1306
1400
  end
1307
- ArgsBuilder.validate!(opts, CHECKOUT_OPTION_MAP)
1308
1401
 
1309
- flags = build_args(opts, CHECKOUT_OPTION_MAP)
1310
- 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
1311
1405
 
1312
- 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
1313
1417
  end
1418
+ private :translate_checkout_opts
1314
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
+ #
1315
1426
  def checkout_file(version, file)
1316
- arr_opts = []
1317
- arr_opts << version
1318
- arr_opts << file
1319
- command('checkout', *arr_opts)
1427
+ Git::Commands::Checkout::Files.new(self).call(version, pathspec: [file]).stdout
1320
1428
  end
1321
1429
 
1322
- MERGE_OPTION_MAP = [
1323
- { keys: [:no_commit], flag: '--no-commit', type: :boolean },
1324
- { keys: [:no_ff], flag: '--no-ff', type: :boolean },
1325
- { keys: [:m], flag: '-m', type: :valued_space }
1326
- ].freeze
1327
-
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
+ #
1328
1452
  def merge(branch, message = nil, opts = {})
1329
- # For backward compatibility, treat the message arg as the :m option.
1330
- opts[:m] = message if message
1331
- ArgsBuilder.validate!(opts, MERGE_OPTION_MAP)
1453
+ # Handle legacy positional message argument
1454
+ opts = opts.merge(message: message) if message
1332
1455
 
1333
- args = build_args(opts, MERGE_OPTION_MAP)
1334
- args.concat(Array(branch))
1456
+ # Map legacy option names to new interface
1457
+ opts = translate_merge_options(opts)
1335
1458
 
1336
- command('merge', *args)
1459
+ Git::Commands::Merge::Start.new(self).call(*Array(branch), no_edit: true, **opts).stdout
1337
1460
  end
1338
1461
 
1339
- MERGE_BASE_OPTION_MAP = [
1340
- { keys: [:octopus], flag: '--octopus', type: :boolean },
1341
- { keys: [:independent], flag: '--independent', type: :boolean },
1342
- { keys: [:fork_point], flag: '--fork-point', type: :boolean },
1343
- { keys: [:all], flag: '--all', type: :boolean }
1344
- ].freeze
1345
-
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
+ #
1346
1477
  def merge_base(*args)
1347
1478
  opts = args.last.is_a?(Hash) ? args.pop : {}
1348
- ArgsBuilder.validate!(opts, MERGE_BASE_OPTION_MAP)
1349
-
1350
- flags = build_args(opts, MERGE_BASE_OPTION_MAP)
1351
- command_args = flags + args
1352
-
1353
- 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?)
1354
1481
  end
1355
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
+ #
1356
1493
  def unmerged
1357
- unmerged = []
1358
- command_lines('diff', '--cached').each do |line|
1359
- 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 (.*)/
1360
1497
  end
1361
- unmerged
1362
1498
  end
1363
1499
 
1364
1500
  def conflicts # :yields: file, your, their
@@ -1374,416 +1510,961 @@ module Git
1374
1510
  end
1375
1511
  end
1376
1512
 
1377
- REMOTE_ADD_OPTION_MAP = [
1378
- { keys: %i[with_fetch fetch], flag: '-f', type: :boolean },
1379
- { keys: [:track], flag: '-t', type: :valued_space }
1380
- ].freeze
1381
-
1382
1513
  def remote_add(name, url, opts = {})
1383
- ArgsBuilder.validate!(opts, REMOTE_ADD_OPTION_MAP)
1384
-
1385
- flags = build_args(opts, REMOTE_ADD_OPTION_MAP)
1386
- positional_args = ['--', name, url]
1387
- 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)
1388
1516
 
1389
- command('remote', *command_args)
1517
+ Git::Commands::Remote::Add.new(self).call(name, url, **translated_opts)
1390
1518
  end
1391
1519
 
1392
- REMOTE_SET_BRANCHES_OPTION_MAP = [
1393
- { keys: [:add], flag: '--add', type: :boolean }
1394
- ].freeze
1395
-
1396
1520
  def remote_set_branches(name, branches, opts = {})
1397
- ArgsBuilder.validate!(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1398
-
1399
- flags = build_args(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1400
- branch_args = Array(branches).flatten
1401
- command_args = ['set-branches'] + flags + [name] + branch_args
1402
-
1403
- command('remote', *command_args)
1521
+ Git::Commands::Remote::SetBranches.new(self).call(name, *Array(branches).flatten, **opts)
1404
1522
  end
1405
1523
 
1406
- def remote_set_url(name, url)
1407
- arr_opts = ['set-url']
1408
- arr_opts << name
1409
- arr_opts << url
1410
-
1411
- command('remote', *arr_opts)
1524
+ def remote_set_url(name, url, opts = {})
1525
+ Git::Commands::Remote::SetUrl.new(self).call(name, url, **opts)
1412
1526
  end
1413
1527
 
1414
1528
  def remote_remove(name)
1415
- command('remote', 'rm', name)
1529
+ Git::Commands::Remote::Remove.new(self).call(name)
1416
1530
  end
1417
1531
 
1418
1532
  def remotes
1419
- command_lines('remote')
1533
+ Git::Commands::Remote::List.new(self).call.stdout.split("\n")
1420
1534
  end
1421
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
+ #
1422
1542
  def tags
1423
- 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)
1424
1545
  end
1425
1546
 
1426
- TAG_OPTION_MAP = [
1427
- { keys: %i[force f], flag: '-f', type: :boolean },
1428
- { keys: %i[annotate a], flag: '-a', type: :boolean },
1429
- { keys: %i[sign s], flag: '-s', type: :boolean },
1430
- { keys: %i[delete d], flag: '-d', type: :boolean },
1431
- { keys: %i[message m], flag: '-m', type: :valued_space }
1432
- ].freeze
1433
-
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
+ #
1434
1627
  def tag(name, *args)
1435
1628
  opts = args.last.is_a?(Hash) ? args.pop : {}
1436
1629
  target = args.first
1437
1630
 
1438
- validate_tag_options!(opts)
1439
- ArgsBuilder.validate!(opts, TAG_OPTION_MAP)
1631
+ if opts[:d] || opts[:delete]
1632
+ delete_tag(name)
1633
+ else
1634
+ validate_tag_options!(opts)
1635
+ create_tag(name, target, opts)
1636
+ end
1637
+ end
1638
+
1639
+ def fetch(remote, opts)
1640
+ opts = opts.dup
1641
+ refspecs = Array(opts.delete(:ref)).compact
1642
+ positionals = [*([remote] if remote), *refspecs]
1643
+ Git::Commands::Fetch.new(self).call(*positionals, **opts, merge: true).stdout
1644
+ end
1645
+
1646
+ PUSH_ALLOWED_OPTS = %i[mirror delete force f push_option all tags].freeze
1647
+
1648
+ # Push refs to a remote repository
1649
+ #
1650
+ # @overload push(options = {})
1651
+ # Push using the current branch's default remote and push configuration
1652
+ #
1653
+ # @param options [Hash] push options
1654
+ #
1655
+ # @option options [Boolean, nil] :all (nil) push all branches
1656
+ #
1657
+ # @option options [Boolean, nil] :mirror (nil) push all refs
1658
+ #
1659
+ # @option options [Boolean, nil] :tags (nil) push all tags
1660
+ #
1661
+ # @option options [Boolean, nil] :force (nil) force updates
1662
+ #
1663
+ # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1664
+ #
1665
+ # @option options [String, Array<String>] :push_option (nil) server-side push option values
1666
+ #
1667
+ # @return [String] the stdout from the final `git push` invocation
1668
+ #
1669
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1670
+ #
1671
+ # @overload push(remote, options = {})
1672
+ # Push to the given remote using the current branch's default push configuration
1673
+ #
1674
+ # @param remote [String] the remote name or URL to push to
1675
+ #
1676
+ # @param options [Hash] push options
1677
+ #
1678
+ # @option options [Boolean, nil] :all (nil) push all branches
1679
+ #
1680
+ # @option options [Boolean, nil] :mirror (nil) push all refs
1681
+ #
1682
+ # @option options [Boolean, nil] :tags (nil) push all tags
1683
+ #
1684
+ # @option options [Boolean, nil] :force (nil) force updates
1685
+ #
1686
+ # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1687
+ #
1688
+ # @option options [String, Array<String>] :push_option (nil) server-side push option values
1689
+ #
1690
+ # @return [String] the stdout from the final `git push` invocation
1691
+ #
1692
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1693
+ #
1694
+ # @overload push(remote, branch, options = {})
1695
+ # Push a branch or refspec to the given remote
1696
+ #
1697
+ # @param remote [String] the remote name or URL to push to
1698
+ #
1699
+ # @param branch [String] the branch name or refspec to push
1700
+ #
1701
+ # @param options [Hash] push options
1702
+ #
1703
+ # @option options [Boolean, nil] :all (nil) push all branches
1704
+ #
1705
+ # @option options [Boolean, nil] :mirror (nil) push all refs
1706
+ #
1707
+ # @option options [Boolean, nil] :tags (nil) push all tags
1708
+ #
1709
+ # @option options [Boolean, nil] :force (nil) force updates
1710
+ #
1711
+ # @option options [Boolean, nil] :delete (nil) delete the named remote ref
1712
+ #
1713
+ # @option options [String, Array<String>] :push_option (nil) server-side push option values
1714
+ #
1715
+ # @return [String] the stdout from the final `git push` invocation
1716
+ #
1717
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1718
+ #
1719
+ # @raise [ArgumentError] if `remote` is nil
1720
+ #
1721
+ # @overload push(remote, branch, tags)
1722
+ # Backward-compatible shorthand for `push(remote, branch, tags: tags)`
1723
+ #
1724
+ # @param remote [String] the remote name or URL to push to
1725
+ #
1726
+ # @param branch [String] the branch name or refspec to push
1727
+ #
1728
+ # @param tags [Boolean] whether to push all tags
1729
+ #
1730
+ # @return [String] the stdout from the final `git push` invocation
1731
+ #
1732
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1733
+ #
1734
+ # @raise [ArgumentError] if `remote` is nil
1735
+ #
1736
+ def push(remote = nil, branch = nil, opts = nil)
1737
+ remote, branch, opts = normalize_push_args(remote, branch, opts)
1738
+ validate_push_args!(remote, branch, opts)
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
1744
+ end
1440
1745
 
1441
- flags = build_args(opts, TAG_OPTION_MAP)
1442
- positional_args = [name, target].compact
1746
+ PULL_ALLOWED_OPTS = %i[allow_unrelated_histories].freeze
1443
1747
 
1444
- command('tag', *flags, *positional_args)
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
1445
1755
  end
1446
1756
 
1447
- FETCH_OPTION_MAP = [
1448
- { keys: [:all], flag: '--all', type: :boolean },
1449
- { keys: %i[tags t], flag: '--tags', type: :boolean },
1450
- { keys: %i[prune p], flag: '--prune', type: :boolean },
1451
- { keys: %i[prune-tags P], flag: '--prune-tags', type: :boolean },
1452
- { keys: %i[force f], flag: '--force', type: :boolean },
1453
- { keys: %i[update-head-ok u], flag: '--update-head-ok', type: :boolean },
1454
- { keys: [:unshallow], flag: '--unshallow', type: :boolean },
1455
- { keys: [:depth], flag: '--depth', type: :valued_space },
1456
- { keys: [:ref], type: :validate_only }
1457
- ].freeze
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)
1458
1769
 
1459
- def fetch(remote, opts)
1460
- ArgsBuilder.validate!(opts, FETCH_OPTION_MAP)
1461
- args = build_args(opts, FETCH_OPTION_MAP)
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
1462
1781
 
1463
- if remote || opts[:ref]
1464
- args << '--'
1465
- args << remote if remote
1466
- 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)
1467
1868
  end
1869
+ apply_gzip(file) if gzip
1468
1870
 
1469
- command('fetch', *args, merge: true)
1871
+ file
1470
1872
  end
1471
1873
 
1472
- PUSH_OPTION_MAP = [
1473
- { keys: [:mirror], flag: '--mirror', type: :boolean },
1474
- { keys: [:delete], flag: '--delete', type: :boolean },
1475
- { keys: %i[force f], flag: '--force', type: :boolean },
1476
- { keys: [:push_option], flag: '--push-option', type: :repeatable_valued_space },
1477
- { keys: [:all], type: :validate_only }, # For validation purposes
1478
- { keys: [:tags], type: :validate_only } # From the `push` method's logic
1479
- ].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
1480
1895
 
1481
- def push(remote = nil, branch = nil, opts = nil)
1482
- remote, branch, opts = normalize_push_args(remote, branch, opts)
1483
- 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
1484
1907
 
1485
- 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
1486
1917
 
1487
- 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
1488
1931
 
1489
- if opts[:mirror]
1490
- command('push', *args)
1491
- else
1492
- command('push', *args)
1493
- 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."
1494
2001
  end
2002
+ true
1495
2003
  end
1496
2004
 
1497
- PULL_OPTION_MAP = [
1498
- { 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
1499
2030
  ].freeze
1500
2031
 
1501
- def pull(remote = nil, branch = nil, opts = {})
1502
- 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?
2120
+
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
1503
2135
 
1504
- ArgsBuilder.validate!(opts, PULL_OPTION_MAP)
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
1505
2191
 
1506
- flags = build_args(opts, PULL_OPTION_MAP)
1507
- 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?
1508
2194
 
1509
- 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)
1510
2198
  end
1511
2199
 
1512
- def tag_sha(tag_name)
1513
- head = File.join(@git_dir, 'refs', 'tags', tag_name)
1514
- return File.read(head).chomp if File.exist?(head)
1515
-
1516
- begin
1517
- command('show-ref', '--tags', '-s', tag_name)
1518
- rescue Git::FailedError => e
1519
- raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
1520
-
1521
- ''
1522
- end
1523
- end
2200
+ private
1524
2201
 
1525
- def repack
1526
- 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.')
1527
2205
  end
1528
2206
 
1529
- def gc
1530
- command('gc', '--prune', '--aggressive', '--auto')
1531
- end
1532
-
1533
- FSCK_OPTION_MAP = [
1534
- { flag: '--no-progress', type: :static },
1535
- { keys: [:unreachable], flag: '--unreachable', type: :boolean },
1536
- { keys: [:strict], flag: '--strict', type: :boolean },
1537
- { keys: [:connectivity_only], flag: '--connectivity-only', type: :boolean },
1538
- { keys: [:root], flag: '--root', type: :boolean },
1539
- { keys: [:tags], flag: '--tags', type: :boolean },
1540
- { keys: [:cache], flag: '--cache', type: :boolean },
1541
- { keys: [:no_reflogs], flag: '--no-reflogs', type: :boolean },
1542
- { keys: [:lost_found], flag: '--lost-found', type: :boolean },
1543
- { keys: [:dangling], flag: '--dangling', type: :boolean_negatable },
1544
- { keys: [:full], flag: '--full', type: :boolean_negatable },
1545
- { keys: [:name_objects], flag: '--name-objects', type: :boolean_negatable },
1546
- { keys: [:references], flag: '--references', type: :boolean_negatable }
1547
- ].freeze
2207
+ def deprecate_clean_option(opts, key, message)
2208
+ return opts unless opts.key?(key)
1548
2209
 
1549
- def fsck(*objects, **opts)
1550
- args = ArgsBuilder.build(opts, FSCK_OPTION_MAP)
1551
- args.concat(objects) unless objects.empty?
1552
- # fsck returns non-zero exit status when issues are found:
1553
- # 1 = errors found, 2 = missing objects, 4 = warnings
1554
- # We still want to parse the output in these cases
1555
- output = begin
1556
- command('fsck', *args)
1557
- rescue Git::FailedError => e
1558
- raise unless [1, 2, 4].include?(e.result.status.exitstatus)
1559
-
1560
- e.result.stdout
1561
- end
1562
- parse_fsck_output(output)
1563
- end
2210
+ opts = opts.dup
2211
+ deprecated_value = opts.delete(key)
2212
+ validate_deprecated_clean_option_value!(key, deprecated_value)
1564
2213
 
1565
- READ_TREE_OPTION_MAP = [
1566
- { keys: [:prefix], flag: '--prefix', type: :valued_equals }
1567
- ].freeze
2214
+ Git::Deprecation.warn(message)
2215
+ return opts unless deprecated_value
1568
2216
 
1569
- def read_tree(treeish, opts = {})
1570
- ArgsBuilder.validate!(opts, READ_TREE_OPTION_MAP)
1571
- flags = build_args(opts, READ_TREE_OPTION_MAP)
1572
- command('read-tree', *flags, treeish)
2217
+ opts[:force] = merge_clean_force_option(opts[:force], force_specified: force_option_specified?(opts))
2218
+ opts
1573
2219
  end
1574
2220
 
1575
- def write_tree
1576
- command('write-tree')
2221
+ def force_option_specified?(opts)
2222
+ opts.key?(:force) && !opts[:force].nil?
1577
2223
  end
1578
2224
 
1579
- COMMIT_TREE_OPTION_MAP = [
1580
- { keys: %i[parent parents], flag: '-p', type: :repeatable_valued_space },
1581
- { keys: [:message], flag: '-m', type: :valued_space }
1582
- ].freeze
1583
-
1584
- def commit_tree(tree, opts = {})
1585
- opts[:message] ||= "commit tree #{tree}"
1586
- ArgsBuilder.validate!(opts, COMMIT_TREE_OPTION_MAP)
1587
-
1588
- flags = build_args(opts, COMMIT_TREE_OPTION_MAP)
1589
- command('commit-tree', tree, *flags)
1590
- end
2225
+ def validate_deprecated_clean_option_value!(key, value)
2226
+ return if value.nil? || value == true || value == false
1591
2227
 
1592
- def update_ref(ref, commit)
1593
- command('update-ref', ref, commit)
2228
+ raise ArgumentError, "#{key} option only accepts true, false, or nil"
1594
2229
  end
1595
2230
 
1596
- CHECKOUT_INDEX_OPTION_MAP = [
1597
- { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1598
- { keys: [:force], flag: '--force', type: :boolean },
1599
- { keys: [:all], flag: '--all', type: :boolean },
1600
- { keys: [:path_limiter], type: :validate_only }
1601
- ].freeze
2231
+ def merge_clean_force_option(existing_force, force_specified: false)
2232
+ return 2 unless force_specified
1602
2233
 
1603
- def checkout_index(opts = {})
1604
- ArgsBuilder.validate!(opts, CHECKOUT_INDEX_OPTION_MAP)
1605
- args = build_args(opts, CHECKOUT_INDEX_OPTION_MAP)
2234
+ normalized_force = normalize_clean_force_option(existing_force)
1606
2235
 
1607
- if (path = opts[:path_limiter]) && path.is_a?(String)
1608
- 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
1609
2242
  end
1610
-
1611
- command('checkout-index', *args)
1612
2243
  end
1613
2244
 
1614
- ARCHIVE_OPTION_MAP = [
1615
- { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1616
- { keys: [:remote], flag: '--remote', type: :valued_equals },
1617
- # These options are used by helpers or handled manually
1618
- { keys: [:path], type: :validate_only },
1619
- { keys: [:format], type: :validate_only },
1620
- { keys: [:add_gzip], type: :validate_only }
1621
- ].freeze
1622
-
1623
- def archive(sha, file = nil, opts = {})
1624
- ArgsBuilder.validate!(opts, ARCHIVE_OPTION_MAP)
1625
- file ||= temp_file_name
1626
- format, gzip = parse_archive_format_options(opts)
1627
-
1628
- args = build_args(opts, ARCHIVE_OPTION_MAP)
1629
- args.unshift("--format=#{format}")
1630
- args << sha
1631
- args.push('--', opts[:path]) if opts[:path]
1632
-
1633
- File.open(file, 'wb') { |f| command('archive', *args, out: f) }
1634
- apply_gzip(file) if gzip
2245
+ def merge_integer_clean_force_option(normalized_force)
2246
+ return normalized_force if normalized_force < 1
1635
2247
 
1636
- file
2248
+ [normalized_force, 2].max
1637
2249
  end
1638
2250
 
1639
- # returns the current version of git, as an Array of Fixnums.
1640
- def current_command_version
1641
- output = command('version')
1642
- version = output[/\d+(\.\d+)+/]
1643
- version_parts = version.split('.').collect(&:to_i)
1644
- 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
1645
2256
  end
1646
2257
 
1647
- # Returns current_command_version <=> other_version
2258
+ # Build a result hash from clone options for Git::Base.new
1648
2259
  #
1649
- # @example
1650
- # 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>'...
1651
2264
  #
1652
- # lib.compare_version_to(2, 41, 0) #=> 1
1653
- # lib.compare_version_to(2, 42, 0) #=> 0
1654
- # lib.compare_version_to(2, 43, 0) #=> -1
2265
+ # @param command_line_result [Git::CommandLineResult] the result of the git clone command
1655
2266
  #
1656
- # @param other_version [Array<Object>] the other version to compare to
1657
- # @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)
1658
2268
  #
1659
- def compare_version_to(*other_version)
1660
- 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
1661
2277
  end
1662
2278
 
1663
- def required_command_version
1664
- [2, 28]
1665
- 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
1666
2301
 
1667
- def meets_required_version?
1668
- (current_command_version <=> required_command_version) >= 0
2302
+ [match[2], !match[1].nil?]
1669
2303
  end
1670
2304
 
1671
- def self.warn_if_old_command(lib) # rubocop:disable Naming/PredicateMethod
1672
- Git::Deprecation.warn('Git::Lib#warn_if_old_command is deprecated. Use meets_required_version?.')
1673
-
1674
- 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
1675
2317
 
1676
- @version_checked = true
1677
- unless lib.meets_required_version?
1678
- warn "[WARNING] The git gem requires git #{lib.required_command_version.join('.')} or later, " \
1679
- "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)
1680
2320
  end
1681
- true
1682
2321
  end
1683
2322
 
1684
- COMMAND_ARG_DEFAULTS = {
1685
- out: nil,
1686
- err: nil,
1687
- normalize: true,
1688
- chomp: true,
1689
- merge: false,
1690
- chdir: nil,
1691
- timeout: nil # Don't set to Git.config.timeout here since it is mutable
1692
- }.freeze
1693
-
1694
- STATIC_GLOBAL_OPTS = %w[
1695
- -c core.quotePath=true
1696
- -c color.ui=false
1697
- -c color.advice=false
1698
- -c color.diff=false
1699
- -c color.grep=false
1700
- -c color.push=false
1701
- -c color.remote=false
1702
- -c color.showBranch=false
1703
- -c color.status=false
1704
- -c color.transport=false
1705
- ].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)
1706
2333
 
1707
- LOG_OPTION_MAP = [
1708
- { type: :static, flag: '--no-color' },
1709
- { keys: [:all], flag: '--all', type: :boolean },
1710
- { keys: [:cherry], flag: '--cherry', type: :boolean },
1711
- { keys: [:since], flag: '--since', type: :valued_equals },
1712
- { keys: [:until], flag: '--until', type: :valued_equals },
1713
- { keys: [:grep], flag: '--grep', type: :valued_equals },
1714
- { keys: [:author], flag: '--author', type: :valued_equals },
1715
- { keys: [:count], flag: '--max-count', type: :valued_equals },
1716
- { keys: [:between], type: :custom, builder: ->(value) { "#{value[0]}..#{value[1]}" if value } }
1717
- ].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
1718
2338
 
1719
- FSCK_OBJECT_PATTERN = /\A(dangling|missing|unreachable) (\w+) ([0-9a-f]{40})(?: \((.+)\))?\z/
1720
- FSCK_WARNING_PATTERN = /\Awarning in (\w+) ([0-9a-f]{40}): (.+)\z/
1721
- FSCK_ROOT_PATTERN = /\Aroot ([0-9a-f]{40})\z/
1722
- 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)
1723
2341
 
1724
- 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
1725
2345
 
1726
- private
2346
+ def deprecate_clone_remote_option!(opts)
2347
+ return unless opts.key?(:remote)
1727
2348
 
1728
- def parse_fsck_output(output)
1729
- result = { dangling: [], missing: [], unreachable: [], warnings: [], root: [], tagged: [] }
1730
- output.each_line { |line| parse_fsck_line(line.strip, result) }
1731
- 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)
1732
2351
  end
1733
2352
 
1734
- def parse_fsck_line(line, result)
1735
- parse_fsck_object_line(line, result) ||
1736
- parse_fsck_warning_line(line, result) ||
1737
- parse_fsck_root_line(line, result) ||
1738
- 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)
1739
2357
  end
1740
2358
 
1741
- def parse_fsck_object_line(line, result)
1742
- return unless (match = FSCK_OBJECT_PATTERN.match(line))
2359
+ def deprecate_commit_add_all_option!(opts)
2360
+ return unless opts.key?(:add_all)
1743
2361
 
1744
- 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)
1745
2364
  end
1746
2365
 
1747
- def parse_fsck_warning_line(line, result)
1748
- return unless (match = FSCK_WARNING_PATTERN.match(line))
1749
-
1750
- 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
1751
2376
  end
1752
2377
 
1753
- def parse_fsck_root_line(line, result)
1754
- 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)
1755
2388
 
1756
- result[:root] << Git::FsckObject.new(type: :commit, sha: match[1])
2389
+ result
1757
2390
  end
1758
2391
 
1759
- def parse_fsck_tagged_line(line, result)
1760
- 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?(':')
1761
2406
 
1762
- 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
1763
2413
  end
1764
2414
 
1765
- def parse_diff_path_status(args)
1766
- command_lines('diff', *args).each_with_object({}) do |line, memo|
1767
- status, path = split_status_line(line)
1768
- memo[path] = status
1769
- 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
1770
2428
  end
1771
2429
 
1772
- def build_checkout_positional_args(branch, opts)
1773
- args = []
1774
- if opts[:new_branch] || opts[:b]
1775
- args.push('-b', branch)
1776
- args << opts[:start_point] if opts[:start_point]
1777
- elsif branch
1778
- args << branch
1779
- end
1780
- 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/) }
1781
2442
  end
1782
2443
 
1783
2444
  def build_args(opts, option_map)
1784
2445
  Git::ArgsBuilder.new(opts, option_map).build
1785
2446
  end
1786
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
+
1787
2468
  def initialize_from_base(base_object)
1788
2469
  @git_dir = base_object.repo.to_s
1789
2470
  @git_index_file = base_object.index&.to_s
@@ -1798,15 +2479,6 @@ module Git
1798
2479
  @git_ssh = base_hash.key?(:git_ssh) ? base_hash[:git_ssh] : :use_global_config
1799
2480
  end
1800
2481
 
1801
- def return_base_opts_from_clone(clone_dir, opts)
1802
- base_opts = {}
1803
- base_opts[:repository] = clone_dir if opts[:bare] || opts[:mirror]
1804
- base_opts[:working_directory] = clone_dir unless opts[:bare] || opts[:mirror]
1805
- base_opts[:log] = opts[:log] if opts[:log]
1806
- base_opts[:git_ssh] = opts[:git_ssh] if opts.key?(:git_ssh)
1807
- base_opts
1808
- end
1809
-
1810
2482
  def process_commit_headers(data)
1811
2483
  headers = { 'parent' => [] } # Pre-initialize for multiple parents
1812
2484
  each_cat_file_header(data) do |key, value|
@@ -1819,44 +2491,8 @@ module Git
1819
2491
  headers
1820
2492
  end
1821
2493
 
1822
- def parse_branch_line(line, index, all_lines)
1823
- match_data = match_branch_line(line, index, all_lines)
1824
-
1825
- return nil if match_data[:not_a_branch] || match_data[:detached_ref]
1826
-
1827
- format_branch_data(match_data)
1828
- end
1829
-
1830
- def match_branch_line(line, index, all_lines)
1831
- match_data = line.match(BRANCH_LINE_REGEXP)
1832
- raise Git::UnexpectedResultError, unexpected_branch_line_error(all_lines, line, index) unless match_data
1833
-
1834
- match_data
1835
- end
1836
-
1837
- def format_branch_data(match_data)
1838
- [
1839
- match_data[:refname],
1840
- !match_data[:current].nil?,
1841
- !match_data[:worktree].nil?,
1842
- match_data[:symref]
1843
- ]
1844
- end
1845
-
1846
- def unexpected_branch_line_error(lines, line, index)
1847
- <<~ERROR
1848
- Unexpected line in output from `git branch -a`, line #{index + 1}
1849
-
1850
- Full output:
1851
- #{lines.join("\n ")}
1852
-
1853
- Line #{index + 1}:
1854
- "#{line}"
1855
- ERROR
1856
- end
1857
-
1858
2494
  def get_branch_state(branch_name)
1859
- command('rev-parse', '--verify', '--quiet', branch_name)
2495
+ Git::Commands::RevParse.new(self).call(branch_name, verify: true, quiet: true)
1860
2496
  :active
1861
2497
  rescue Git::FailedError => e
1862
2498
  # An exit status of 1 with empty stderr from `rev-parse --verify`
@@ -1866,23 +2502,14 @@ module Git
1866
2502
  :unborn
1867
2503
  end
1868
2504
 
1869
- def execute_grep_command(args)
1870
- command_lines('grep', *args)
1871
- rescue Git::FailedError => e
1872
- # `git grep` returns 1 when no lines are selected.
1873
- raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
1874
-
1875
- [] # 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
1876
2509
  end
1877
2510
 
1878
- def parse_grep_output(lines)
1879
- lines.each_with_object(Hash.new { |h, k| h[k] = [] }) do |line, hsh|
1880
- match = line.match(/\A(.*?):(\d+):(.*)/)
1881
- next unless match
1882
-
1883
- _full, filename, line_num, text = match.to_a
1884
- hsh[filename] << [line_num.to_i, text]
1885
- end
2511
+ def parse_grep_output(output)
2512
+ Git::Parsers::Grep.parse(output)
1886
2513
  end
1887
2514
 
1888
2515
  def parse_diff_stats_output(lines)
@@ -1907,6 +2534,18 @@ module Git
1907
2534
  parts
1908
2535
  end
1909
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
+
1910
2549
  def build_final_stats_hash(file_stats)
1911
2550
  {
1912
2551
  total: build_total_stats(file_stats),
@@ -1952,45 +2591,48 @@ module Git
1952
2591
  [type, name, value]
1953
2592
  end
1954
2593
 
1955
- def stash_log_lines
1956
- path = File.join(@git_dir, 'logs/refs/stash')
1957
- return [] unless File.exist?(path)
1958
-
1959
- File.readlines(path, chomp: true)
1960
- end
1961
-
1962
- def parse_stash_log_line(line, index)
1963
- 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
1964
2606
  match_data = full_message.match(/^[^:]+:(.*)$/)
1965
2607
  message = match_data ? match_data[1] : full_message
1966
2608
 
1967
2609
  [index, message.strip]
1968
2610
  end
1969
2611
 
1970
- # 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
1971
2618
  #
1972
2619
  # @param path [String] the path to the file in the index
1973
2620
  #
1974
- # @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)
2622
+ #
2623
+ # @param out_io [IO] the `IO` object to stream the staged content into
2624
+ #
2625
+ # @return [IO] `out_io`, as passed in
1975
2626
  #
1976
- # @param out_io [IO] the IO object to write the staged content to
2627
+ # @raise [Git::FailedError] if the object does not exist or git exits non-zero
1977
2628
  #
1978
- # @return [IO] the IO object that was written to
2629
+ # @raise [Git::TimeoutError] if the command exceeds the configured timeout
1979
2630
  #
1980
2631
  def write_staged_content(path, stage, out_io)
1981
- command('show', ":#{stage}:#{path}", out: out_io)
2632
+ Git::Commands::Show.new(self).call(":#{stage}:#{path}", out: out_io)
1982
2633
  out_io
1983
2634
  end
1984
2635
 
1985
- def validate_tag_options!(opts)
1986
- is_annotated = opts[:a] || opts[:annotate]
1987
- has_message = opts[:m] || opts[:message]
1988
-
1989
- return unless is_annotated && !has_message
1990
-
1991
- raise ArgumentError, 'Cannot create an annotated tag without a message.'
1992
- end
1993
-
1994
2636
  def normalize_push_args(remote, branch, opts)
1995
2637
  if branch.is_a?(Hash)
1996
2638
  opts = branch
@@ -2006,15 +2648,22 @@ module Git
2006
2648
  [remote, branch, opts]
2007
2649
  end
2008
2650
 
2009
- def build_push_args(remote, branch, opts)
2010
- # Build the simple flags using the ArgsBuilder
2011
- 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
2012
2664
 
2013
- # Manually handle the flag with external dependencies and positional args
2014
- args << '--all' if opts[:all] && remote
2015
- args << remote if remote
2016
- args << branch if branch
2017
- args
2665
+ def push_tags(remote, opts)
2666
+ Git::Commands::Push.new(self).call(*[remote].compact, **opts)
2018
2667
  end
2019
2668
 
2020
2669
  def temp_file_name
@@ -2036,16 +2685,6 @@ module Git
2036
2685
  Zlib::GzipWriter.open(file) { |gz| gz.write(file_content) }
2037
2686
  end
2038
2687
 
2039
- def command_lines(cmd, *opts, chdir: nil)
2040
- cmd_op = command(cmd, *opts, chdir: chdir)
2041
- op = if cmd_op.encoding.name == 'UTF-8'
2042
- cmd_op
2043
- else
2044
- cmd_op.encode('UTF-8', 'binary', invalid: :replace, undef: :replace)
2045
- end
2046
- op.split("\n")
2047
- end
2048
-
2049
2688
  # Returns a hash of environment variable overrides for git commands
2050
2689
  #
2051
2690
  # This method builds a hash of environment variables that control git's behavior,
@@ -2086,6 +2725,7 @@ module Git
2086
2725
  'GIT_WORK_TREE' => @git_work_dir,
2087
2726
  'GIT_INDEX_FILE' => @git_index_file,
2088
2727
  'GIT_SSH' => resolved_git_ssh,
2728
+ 'GIT_EDITOR' => 'true', # Use a no-op editor so Git skips interactive editing but continues
2089
2729
  'LC_ALL' => 'en_US.UTF-8'
2090
2730
  }.merge(additional_overrides)
2091
2731
  end
@@ -2113,154 +2753,103 @@ module Git
2113
2753
  end
2114
2754
  end
2115
2755
 
2116
- def command_line
2117
- @command_line ||=
2118
- Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2119
- end
2120
-
2121
- # Returns a command line instance without GIT_INDEX_FILE for worktree commands
2756
+ # Returns the {Git::CommandLine::Capturing} instance used for capturing execution
2122
2757
  #
2123
- # Git worktrees manage their own index files and setting GIT_INDEX_FILE
2124
- # causes corruption of both the main worktree and new worktree indexes.
2758
+ # Memoized factory for the capturing execution path. Instantiates
2759
+ # {Git::CommandLine::Capturing} with the current environment, binary path,
2760
+ # global options, and logger.
2125
2761
  #
2126
- # @return [Git::CommandLine]
2127
- # @api private
2762
+ # @return [Git::CommandLine::Capturing]
2763
+ #
2764
+ # @see Git::CommandLine::Capturing#run
2128
2765
  #
2129
- def worktree_command_line
2130
- @worktree_command_line ||=
2131
- Git::CommandLine.new(env_overrides('GIT_INDEX_FILE' => nil), Git::Base.config.binary_path, global_opts,
2132
- @logger)
2766
+ def command_line_capturing
2767
+ @command_line_capturing ||=
2768
+ Git::CommandLine::Capturing.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2133
2769
  end
2134
2770
 
2135
- # @overload worktree_command(*args, **options_hash)
2136
- # Runs a git worktree command and returns the output
2137
- #
2138
- # This method is similar to #command but uses a command line instance
2139
- # that excludes GIT_INDEX_FILE from the environment to prevent index corruption.
2140
- #
2141
- # @param args [Array<String>] the command arguments
2142
- # @param options_hash [Hash] the options to pass to the command
2771
+ # Returns the {Git::CommandLine::Streaming} instance used for streaming execution
2143
2772
  #
2144
- # @return [String] the command's stdout
2773
+ # Memoized factory for the streaming execution path. Instantiates
2774
+ # {Git::CommandLine::Streaming} with the current environment, binary path,
2775
+ # global options, and logger.
2145
2776
  #
2146
- # @see #command
2777
+ # @return [Git::CommandLine::Streaming]
2147
2778
  #
2148
- # @api private
2779
+ # @see Git::CommandLine::Streaming#run
2149
2780
  #
2150
- def worktree_command(*, **options_hash)
2151
- options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2152
- options_hash[:timeout] ||= Git.config.timeout
2153
-
2154
- extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2155
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2156
-
2157
- result = worktree_command_line.run(*, **options_hash)
2158
- 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)
2159
2784
  end
2160
2785
 
2161
- # Runs a git command and returns the output
2162
- #
2163
- # Additional args are passed to the command line. They should exclude the 'git'
2164
- # command itself and global options. Remember to splat the the arguments if given
2165
- # as an array.
2166
- #
2167
- # For example, to run `git log --pretty=oneline`, you would create the array
2168
- # `args = ['log', '--pretty=oneline']` and call `command(*args)`.
2169
- #
2170
- # @param options_hash [Hash] the options to pass to the command
2171
- # @option options_hash [IO, String, #write, nil] :out the destination for captured stdout
2172
- # @option options_hash [IO, String, #write, nil] :err the destination for captured stderr
2173
- # @option options_hash [Boolean] :normalize true to normalize the output encoding to UTF-8
2174
- # @option options_hash [Boolean] :chomp true to remove trailing newlines from the output
2175
- # @option options_hash [Boolean] :merge true to merge stdout and stderr into a single output
2176
- # @option options_hash [String, nil] :chdir the directory to run the command in
2177
- # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command to complete
2178
- #
2179
- # If timeout is nil, the global timeout from {Git::Config} is used.
2180
- #
2181
- # If timeout is zero, the timeout will not be enforced.
2182
- #
2183
- # If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised.
2184
- #
2185
- # If the command does not respond to SIGKILL, it will hang this method.
2186
- #
2187
- # @see Git::CommandLine#run
2188
- #
2189
- # @return [String] the command's stdout (or merged stdout and stderr if `merge`
2190
- # is true)
2191
- #
2192
- # @raise [ArgumentError] if an unknown option is passed
2193
- #
2194
- # @raise [Git::FailedError] if the command failed
2195
- #
2196
- # @raise [Git::SignaledError] if the command was signaled
2197
- #
2198
- # @raise [Git::TimeoutError] if the command times out
2199
- #
2200
- # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2201
- #
2202
- # The exception's `result` attribute is a {Git::CommandLineResult} which will
2203
- # contain the result of the command including the exit status, stdout, and
2204
- # stderr.
2205
- #
2206
- # @api private
2786
+ # Validates the :count option for log commands.
2207
2787
  #
2208
- def command(*, **options_hash)
2209
- options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2210
- options_hash[:timeout] ||= Git.config.timeout
2788
+ def validate_log_count_option!(opts)
2789
+ return unless opts[:count] && !opts[:count].is_a?(Integer)
2211
2790
 
2212
- extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2213
- raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2214
-
2215
- result = command_line.run(*, **options_hash)
2216
- result.stdout
2791
+ raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
2217
2792
  end
2218
2793
 
2219
- # Takes the diff command line output (as Array) and parse it into a Hash
2794
+ # Builds the positional revision range argument(s) from opts for Git::Commands::Log
2220
2795
  #
2221
- # @param [String] diff_command the diff commadn to be used
2222
- # @param [Array] opts the diff options to be used
2223
- # @return [Hash] the diff as Hash
2224
- def diff_as_hash(diff_command, opts = [])
2225
- # update index before diffing to avoid spurious diffs
2226
- command('status')
2227
- command_lines(diff_command, *opts).each_with_object({}) do |line, memo|
2228
- info, file = split_status_line(line)
2229
- mode_src, mode_dest, sha_src, sha_dest, type = info.split
2230
-
2231
- memo[file] = {
2232
- mode_index: mode_dest, mode_repo: mode_src.to_s[1, 7],
2233
- path: file, sha_repo: sha_src, sha_index: sha_dest,
2234
- type: type
2235
- }
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
+ []
2236
2805
  end
2237
2806
  end
2238
2807
 
2239
- # Returns an array holding the common options for the log commands
2808
+ # Builds the common keyword options for Git::Commands::Log from opts
2240
2809
  #
2241
- # @param [Hash] opts the given options
2242
- # @return [Array] the set of common options that the log command will use
2243
- def log_common_options(opts)
2244
- if opts[:count] && !opts[:count].is_a?(Integer)
2245
- raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
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"))
2246
2834
  end
2247
-
2248
- build_args(opts, LOG_OPTION_MAP)
2249
2835
  end
2250
2836
 
2251
- # Retrurns an array holding path options for the log commands
2252
- #
2253
- # @param [Hash] opts the given options
2254
- # @return [Array] the set of path options that the log command will use
2255
- def log_path_options(opts)
2256
- arr_opts = []
2837
+ def log_or_empty_on_unborn
2838
+ yield
2839
+ rescue Git::FailedError => e
2840
+ raise unless e.result.status.exitstatus == 128 &&
2841
+ e.result.stderr =~ /does not have any commits yet/
2842
+
2843
+ []
2844
+ end
2257
2845
 
2258
- arr_opts << opts[:object] if opts[:object].is_a? String
2259
- if opts[:path_limiter]
2260
- arr_opts << '--'
2261
- arr_opts += Array(opts[:path_limiter])
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?
2262
2852
  end
2263
- arr_opts
2264
2853
  end
2265
2854
  end
2266
2855
  end