git 4.3.2 → 5.0.0.beta.2

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 (297) hide show
  1. checksums.yaml +4 -4
  2. data/.github/copilot-instructions.md +73 -2705
  3. data/.github/prompts/iteratively-address-copilot-reviews.prompt.md +188 -0
  4. data/.github/pull_request_template.md +3 -1
  5. data/.github/skills/breaking-change-analysis/SKILL.md +102 -0
  6. data/.github/skills/ci-cd-troubleshooting/SKILL.md +264 -0
  7. data/.github/skills/command-implementation/REFERENCE.md +993 -0
  8. data/.github/skills/command-implementation/SKILL.md +229 -0
  9. data/.github/skills/command-test-conventions/SKILL.md +660 -0
  10. data/.github/skills/command-yard-documentation/SKILL.md +426 -0
  11. data/.github/skills/dependency-management/SKILL.md +72 -0
  12. data/.github/skills/development-workflow/SKILL.md +506 -0
  13. data/.github/skills/extract-command-from-lib/SKILL.md +487 -0
  14. data/.github/skills/extract-facade-from-base-lib/KEYWORD_ARG_REMEDIATION.md +22 -0
  15. data/.github/skills/extract-facade-from-base-lib/SKILL.md +600 -0
  16. data/.github/skills/facade-implementation/REFERENCE.md +840 -0
  17. data/.github/skills/facade-implementation/SKILL.md +274 -0
  18. data/.github/skills/facade-test-conventions/SKILL.md +394 -0
  19. data/.github/skills/facade-yard-documentation/SKILL.md +429 -0
  20. data/.github/skills/make-skill-template/SKILL.md +176 -0
  21. data/.github/skills/pr-readiness-review/SKILL.md +185 -0
  22. data/.github/skills/project-context/SKILL.md +313 -0
  23. data/.github/skills/pull-request-review/SKILL.md +168 -0
  24. data/.github/skills/refactor-command-to-commandlineresult/SKILL.md +131 -0
  25. data/.github/skills/release-management/SKILL.md +125 -0
  26. data/.github/skills/review-arguments-dsl/CHECKLIST.md +788 -0
  27. data/.github/skills/review-arguments-dsl/SKILL.md +214 -0
  28. data/.github/skills/review-backward-compatibility/SKILL.md +275 -0
  29. data/.github/skills/review-cross-command-consistency/SKILL.md +139 -0
  30. data/.github/skills/reviewing-skills/SKILL.md +189 -0
  31. data/.github/skills/rspec-unit-testing-standards/SKILL.md +639 -0
  32. data/.github/skills/tdd-refactor-step/SKILL.md +236 -0
  33. data/.github/skills/test-debugging/SKILL.md +160 -0
  34. data/.github/skills/yard-documentation/SKILL.md +793 -0
  35. data/.github/workflows/continuous_integration.yml +3 -2
  36. data/.github/workflows/enforce_conventional_commits.yml +1 -1
  37. data/.github/workflows/experimental_continuous_integration.yml +2 -2
  38. data/.github/workflows/release.yml +3 -4
  39. data/.gitignore +8 -0
  40. data/.husky/pre-commit +13 -0
  41. data/.release-please-manifest.json +1 -1
  42. data/.rspec +3 -0
  43. data/.rubocop.yml +12 -3
  44. data/.rubocop_todo.yml +23 -5
  45. data/.yardopts +1 -0
  46. data/CHANGELOG.md +0 -40
  47. data/CONTRIBUTING.md +694 -53
  48. data/README.md +67 -15
  49. data/Rakefile +61 -9
  50. data/UPGRADING.md +141 -0
  51. data/commitlint.test +4 -0
  52. data/git.gemspec +18 -7
  53. data/lib/git/args_builder.rb +0 -8
  54. data/lib/git/branch.rb +369 -43
  55. data/lib/git/branch_delete_failure.rb +31 -0
  56. data/lib/git/branch_delete_result.rb +63 -0
  57. data/lib/git/branch_info.rb +178 -0
  58. data/lib/git/branches.rb +122 -24
  59. data/lib/git/command_line/base.rb +255 -0
  60. data/lib/git/command_line/capturing.rb +251 -0
  61. data/lib/git/command_line/result.rb +96 -0
  62. data/lib/git/command_line/streaming.rb +196 -0
  63. data/lib/git/command_line.rb +43 -322
  64. data/lib/git/command_line_result.rb +4 -88
  65. data/lib/git/commands/add.rb +131 -0
  66. data/lib/git/commands/am/abort.rb +43 -0
  67. data/lib/git/commands/am/apply.rb +252 -0
  68. data/lib/git/commands/am/continue.rb +43 -0
  69. data/lib/git/commands/am/quit.rb +43 -0
  70. data/lib/git/commands/am/retry.rb +47 -0
  71. data/lib/git/commands/am/show_current_patch.rb +64 -0
  72. data/lib/git/commands/am/skip.rb +42 -0
  73. data/lib/git/commands/am.rb +33 -0
  74. data/lib/git/commands/apply.rb +237 -0
  75. data/lib/git/commands/archive/list_formats.rb +46 -0
  76. data/lib/git/commands/archive.rb +140 -0
  77. data/lib/git/commands/arguments.rb +3510 -0
  78. data/lib/git/commands/base.rb +404 -0
  79. data/lib/git/commands/branch/copy.rb +94 -0
  80. data/lib/git/commands/branch/create.rb +173 -0
  81. data/lib/git/commands/branch/delete.rb +80 -0
  82. data/lib/git/commands/branch/list.rb +162 -0
  83. data/lib/git/commands/branch/move.rb +94 -0
  84. data/lib/git/commands/branch/set_upstream.rb +86 -0
  85. data/lib/git/commands/branch/show_current.rb +49 -0
  86. data/lib/git/commands/branch/unset_upstream.rb +57 -0
  87. data/lib/git/commands/branch.rb +34 -0
  88. data/lib/git/commands/cat_file/batch.rb +369 -0
  89. data/lib/git/commands/cat_file/filtered.rb +105 -0
  90. data/lib/git/commands/cat_file/raw.rb +216 -0
  91. data/lib/git/commands/cat_file.rb +49 -0
  92. data/lib/git/commands/checkout/branch.rb +151 -0
  93. data/lib/git/commands/checkout/files.rb +115 -0
  94. data/lib/git/commands/checkout.rb +38 -0
  95. data/lib/git/commands/checkout_index.rb +105 -0
  96. data/lib/git/commands/clean.rb +100 -0
  97. data/lib/git/commands/clone.rb +240 -0
  98. data/lib/git/commands/commit.rb +272 -0
  99. data/lib/git/commands/commit_tree.rb +100 -0
  100. data/lib/git/commands/config_option_syntax/add.rb +83 -0
  101. data/lib/git/commands/config_option_syntax/get.rb +117 -0
  102. data/lib/git/commands/config_option_syntax/get_all.rb +115 -0
  103. data/lib/git/commands/config_option_syntax/get_color.rb +91 -0
  104. data/lib/git/commands/config_option_syntax/get_color_bool.rb +93 -0
  105. data/lib/git/commands/config_option_syntax/get_regexp.rb +115 -0
  106. data/lib/git/commands/config_option_syntax/get_urlmatch.rb +107 -0
  107. data/lib/git/commands/config_option_syntax/list.rb +107 -0
  108. data/lib/git/commands/config_option_syntax/remove_section.rb +74 -0
  109. data/lib/git/commands/config_option_syntax/rename_section.rb +78 -0
  110. data/lib/git/commands/config_option_syntax/replace_all.rb +104 -0
  111. data/lib/git/commands/config_option_syntax/set.rb +114 -0
  112. data/lib/git/commands/config_option_syntax/unset.rb +89 -0
  113. data/lib/git/commands/config_option_syntax/unset_all.rb +89 -0
  114. data/lib/git/commands/config_option_syntax.rb +56 -0
  115. data/lib/git/commands/describe.rb +155 -0
  116. data/lib/git/commands/diff.rb +656 -0
  117. data/lib/git/commands/diff_files.rb +518 -0
  118. data/lib/git/commands/diff_index.rb +496 -0
  119. data/lib/git/commands/fetch.rb +352 -0
  120. data/lib/git/commands/fsck.rb +136 -0
  121. data/lib/git/commands/gc.rb +132 -0
  122. data/lib/git/commands/grep.rb +338 -0
  123. data/lib/git/commands/init.rb +99 -0
  124. data/lib/git/commands/log.rb +632 -0
  125. data/lib/git/commands/ls_files.rb +191 -0
  126. data/lib/git/commands/ls_remote.rb +155 -0
  127. data/lib/git/commands/ls_tree.rb +131 -0
  128. data/lib/git/commands/maintenance/register.rb +75 -0
  129. data/lib/git/commands/maintenance/run.rb +104 -0
  130. data/lib/git/commands/maintenance/start.rb +66 -0
  131. data/lib/git/commands/maintenance/stop.rb +55 -0
  132. data/lib/git/commands/maintenance/unregister.rb +79 -0
  133. data/lib/git/commands/maintenance.rb +31 -0
  134. data/lib/git/commands/merge/abort.rb +44 -0
  135. data/lib/git/commands/merge/continue.rb +44 -0
  136. data/lib/git/commands/merge/quit.rb +46 -0
  137. data/lib/git/commands/merge/start.rb +245 -0
  138. data/lib/git/commands/merge.rb +28 -0
  139. data/lib/git/commands/merge_base.rb +86 -0
  140. data/lib/git/commands/mv.rb +77 -0
  141. data/lib/git/commands/name_rev.rb +114 -0
  142. data/lib/git/commands/pull.rb +377 -0
  143. data/lib/git/commands/push.rb +246 -0
  144. data/lib/git/commands/read_tree.rb +149 -0
  145. data/lib/git/commands/remote/add.rb +91 -0
  146. data/lib/git/commands/remote/get_url.rb +66 -0
  147. data/lib/git/commands/remote/list.rb +54 -0
  148. data/lib/git/commands/remote/prune.rb +61 -0
  149. data/lib/git/commands/remote/remove.rb +52 -0
  150. data/lib/git/commands/remote/rename.rb +69 -0
  151. data/lib/git/commands/remote/set_branches.rb +63 -0
  152. data/lib/git/commands/remote/set_head.rb +82 -0
  153. data/lib/git/commands/remote/set_url.rb +71 -0
  154. data/lib/git/commands/remote/set_url_add.rb +61 -0
  155. data/lib/git/commands/remote/set_url_delete.rb +64 -0
  156. data/lib/git/commands/remote/show.rb +71 -0
  157. data/lib/git/commands/remote/update.rb +72 -0
  158. data/lib/git/commands/remote.rb +42 -0
  159. data/lib/git/commands/repack.rb +277 -0
  160. data/lib/git/commands/reset.rb +147 -0
  161. data/lib/git/commands/rev_parse.rb +297 -0
  162. data/lib/git/commands/revert/abort.rb +45 -0
  163. data/lib/git/commands/revert/continue.rb +57 -0
  164. data/lib/git/commands/revert/quit.rb +47 -0
  165. data/lib/git/commands/revert/skip.rb +44 -0
  166. data/lib/git/commands/revert/start.rb +153 -0
  167. data/lib/git/commands/revert.rb +29 -0
  168. data/lib/git/commands/rm.rb +114 -0
  169. data/lib/git/commands/show.rb +632 -0
  170. data/lib/git/commands/show_ref/exclude_existing.rb +120 -0
  171. data/lib/git/commands/show_ref/exists.rb +78 -0
  172. data/lib/git/commands/show_ref/list.rb +145 -0
  173. data/lib/git/commands/show_ref/verify.rb +120 -0
  174. data/lib/git/commands/show_ref.rb +42 -0
  175. data/lib/git/commands/stash/apply.rb +75 -0
  176. data/lib/git/commands/stash/branch.rb +65 -0
  177. data/lib/git/commands/stash/clear.rb +41 -0
  178. data/lib/git/commands/stash/create.rb +58 -0
  179. data/lib/git/commands/stash/drop.rb +67 -0
  180. data/lib/git/commands/stash/list.rb +39 -0
  181. data/lib/git/commands/stash/pop.rb +78 -0
  182. data/lib/git/commands/stash/push.rb +103 -0
  183. data/lib/git/commands/stash/show.rb +149 -0
  184. data/lib/git/commands/stash/store.rb +63 -0
  185. data/lib/git/commands/stash.rb +38 -0
  186. data/lib/git/commands/status.rb +169 -0
  187. data/lib/git/commands/symbolic_ref/delete.rb +68 -0
  188. data/lib/git/commands/symbolic_ref/read.rb +95 -0
  189. data/lib/git/commands/symbolic_ref/update.rb +76 -0
  190. data/lib/git/commands/symbolic_ref.rb +38 -0
  191. data/lib/git/commands/tag/create.rb +139 -0
  192. data/lib/git/commands/tag/delete.rb +55 -0
  193. data/lib/git/commands/tag/list.rb +143 -0
  194. data/lib/git/commands/tag/verify.rb +71 -0
  195. data/lib/git/commands/tag.rb +26 -0
  196. data/lib/git/commands/update_ref/batch.rb +140 -0
  197. data/lib/git/commands/update_ref/delete.rb +92 -0
  198. data/lib/git/commands/update_ref/update.rb +106 -0
  199. data/lib/git/commands/update_ref.rb +42 -0
  200. data/lib/git/commands/version.rb +57 -0
  201. data/lib/git/commands/worktree/add.rb +140 -0
  202. data/lib/git/commands/worktree/list.rb +64 -0
  203. data/lib/git/commands/worktree/lock.rb +58 -0
  204. data/lib/git/commands/worktree/management_base.rb +51 -0
  205. data/lib/git/commands/worktree/move.rb +66 -0
  206. data/lib/git/commands/worktree/prune.rb +67 -0
  207. data/lib/git/commands/worktree/remove.rb +63 -0
  208. data/lib/git/commands/worktree/repair.rb +76 -0
  209. data/lib/git/commands/worktree/unlock.rb +47 -0
  210. data/lib/git/commands/worktree.rb +43 -0
  211. data/lib/git/commands/write_tree.rb +68 -0
  212. data/lib/git/commands.rb +87 -0
  213. data/lib/git/config.rb +17 -0
  214. data/lib/git/config_entry_info.rb +106 -0
  215. data/lib/git/configuring.rb +665 -0
  216. data/lib/git/deprecation.rb +9 -0
  217. data/lib/git/detached_head_info.rb +54 -0
  218. data/lib/git/diff.rb +293 -7
  219. data/lib/git/diff_file_numstat_info.rb +29 -0
  220. data/lib/git/diff_file_patch_info.rb +134 -0
  221. data/lib/git/diff_file_raw_info.rb +127 -0
  222. data/lib/git/diff_info.rb +169 -0
  223. data/lib/git/diff_path_status.rb +67 -19
  224. data/lib/git/diff_result.rb +32 -0
  225. data/lib/git/diff_stats.rb +51 -14
  226. data/lib/git/dirstat_info.rb +86 -0
  227. data/lib/git/errors.rb +65 -2
  228. data/lib/git/execution_context/global.rb +31 -0
  229. data/lib/git/execution_context/repository.rb +136 -0
  230. data/lib/git/execution_context.rb +501 -0
  231. data/lib/git/file_ref.rb +74 -0
  232. data/lib/git/fsck_object.rb +9 -9
  233. data/lib/git/fsck_result.rb +1 -1
  234. data/lib/git/log.rb +9 -2
  235. data/lib/git/object.rb +90 -27
  236. data/lib/git/parsers/branch.rb +224 -0
  237. data/lib/git/parsers/cat_file.rb +111 -0
  238. data/lib/git/parsers/config_entry.rb +110 -0
  239. data/lib/git/parsers/diff.rb +585 -0
  240. data/lib/git/parsers/fsck.rb +133 -0
  241. data/lib/git/parsers/grep.rb +42 -0
  242. data/lib/git/parsers/ls_remote.rb +79 -0
  243. data/lib/git/parsers/ls_tree.rb +58 -0
  244. data/lib/git/parsers/stash.rb +208 -0
  245. data/lib/git/parsers/tag.rb +257 -0
  246. data/lib/git/remote.rb +120 -9
  247. data/lib/git/repository/branching.rb +743 -0
  248. data/lib/git/repository/committing.rb +187 -0
  249. data/lib/git/repository/configuring.rb +351 -0
  250. data/lib/git/repository/context_helpers.rb +264 -0
  251. data/lib/git/repository/diffing.rb +775 -0
  252. data/lib/git/repository/factories.rb +682 -0
  253. data/lib/git/repository/inspecting.rb +252 -0
  254. data/lib/git/repository/logging.rb +247 -0
  255. data/lib/git/repository/maintenance.rb +65 -0
  256. data/lib/git/repository/merging.rb +357 -0
  257. data/lib/git/repository/object_operations.rb +1199 -0
  258. data/lib/git/repository/path_resolver.rb +207 -0
  259. data/lib/git/repository/remote_operations.rb +898 -0
  260. data/lib/git/repository/shared_private.rb +51 -0
  261. data/lib/git/repository/staging.rb +554 -0
  262. data/lib/git/repository/stashing.rb +143 -0
  263. data/lib/git/repository/status_operations.rb +201 -0
  264. data/lib/git/repository/worktree_operations.rb +159 -0
  265. data/lib/git/repository.rb +203 -1
  266. data/lib/git/stash.rb +78 -4
  267. data/lib/git/stash_info.rb +104 -0
  268. data/lib/git/stashes.rb +125 -13
  269. data/lib/git/status.rb +215 -18
  270. data/lib/git/tag_delete_failure.rb +31 -0
  271. data/lib/git/tag_delete_result.rb +63 -0
  272. data/lib/git/tag_info.rb +105 -0
  273. data/lib/git/version.rb +109 -2
  274. data/lib/git/version_constraint.rb +81 -0
  275. data/lib/git/worktree.rb +108 -6
  276. data/lib/git/worktrees.rb +94 -7
  277. data/lib/git.rb +260 -61
  278. data/redesign/1_architecture_existing.md +54 -18
  279. data/redesign/2_architecture_redesign.md +365 -46
  280. data/redesign/3_architecture_implementation.md +1497 -63
  281. data/redesign/Phase 4 - Step A.md +360 -0
  282. data/redesign/beta_release.md +107 -0
  283. data/redesign/c1c2_audit.md +566 -0
  284. data/redesign/c1c2_bucket6_lib_orphans.md +626 -0
  285. data/redesign/config_design.rb +501 -0
  286. data/tasks/gem_tasks.rake +4 -0
  287. data/tasks/npm_tasks.rake +7 -0
  288. data/tasks/rspec.rake +48 -0
  289. data/tasks/test.rake +13 -1
  290. data/tasks/yard.rake +34 -7
  291. metadata +365 -22
  292. data/lib/git/base.rb +0 -1128
  293. data/lib/git/index.rb +0 -6
  294. data/lib/git/lib.rb +0 -2277
  295. data/lib/git/path.rb +0 -38
  296. data/lib/git/working_directory.rb +0 -6
  297. /data/{release-please-config.json → .release-please-config.json} +0 -0
@@ -0,0 +1,1199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'git/commands/archive'
5
+ require 'git/object'
6
+ require 'git/commands/cat_file/raw'
7
+ require 'git/commands/grep'
8
+ require 'git/commands/ls_tree'
9
+ require 'git/commands/name_rev'
10
+ require 'git/commands/rev_parse'
11
+ require 'git/commands/show_ref/list'
12
+ require 'git/commands/tag/create'
13
+ require 'git/commands/tag/delete'
14
+ require 'git/commands/tag/list'
15
+ require 'git/parsers/cat_file'
16
+ require 'git/parsers/grep'
17
+ require 'git/parsers/ls_tree'
18
+ require 'git/parsers/tag'
19
+ require 'git/repository/shared_private'
20
+ require 'git/escaped_path'
21
+ require 'tempfile'
22
+ require 'zlib'
23
+
24
+ module Git
25
+ class Repository
26
+ # Facade methods for raw git object store queries
27
+ #
28
+ # Included by {Git::Repository}.
29
+ #
30
+ # @api public
31
+ #
32
+ module ObjectOperations # rubocop:disable Metrics/ModuleLength
33
+ # Returns the raw content of a git object, or streams it into a tempfile
34
+ #
35
+ # Without a block, the full content is buffered in memory and returned as a
36
+ # `String`. With a block, git output is streamed directly to disk without
37
+ # memory buffering — safe for large blobs.
38
+ #
39
+ # @overload cat_file_contents(object)
40
+ # Returns the object's raw content as a string
41
+ #
42
+ # @example Get the contents of a blob
43
+ # repo.cat_file_contents('HEAD:README.md') # => "This is a README file\n"
44
+ #
45
+ # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
46
+ #
47
+ # @return [String] the raw content of the object
48
+ #
49
+ # @overload cat_file_contents(object, &block)
50
+ # Streams the object's raw content to a temporary file and yields it
51
+ #
52
+ # Git output is written directly to a file on disk without being buffered in
53
+ # memory first, then the file is rewound and yielded to the block. The return
54
+ # value is whatever the block returns.
55
+ #
56
+ # @example Read a large blob without buffering it in memory
57
+ # repo.cat_file_contents('HEAD:large_file.bin') { |f| process(f) }
58
+ #
59
+ # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
60
+ #
61
+ # @yield [file] the temporary file containing the streamed content,
62
+ # positioned at the start
63
+ #
64
+ # @yieldparam file [File] readable `IO` object positioned at the beginning
65
+ # of the content
66
+ #
67
+ # @yieldreturn [Object] the value to return from this method
68
+ #
69
+ # @return [Object] the value returned by the block
70
+ #
71
+ # @raise [ArgumentError] if `object` starts with a hyphen
72
+ #
73
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
74
+ #
75
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
76
+ #
77
+ def cat_file_contents(object)
78
+ raise ArgumentError, "Invalid object: '#{object}'" if object&.start_with?('-')
79
+
80
+ return Git::Commands::CatFile::Raw.new(@execution_context).call(object, p: true).stdout unless block_given?
81
+
82
+ # Stream git output directly to a tempfile to avoid buffering large
83
+ # object content in memory when a block is given.
84
+ Tempfile.create do |file|
85
+ file.binmode
86
+ Git::Commands::CatFile::Raw.new(@execution_context).call(object, p: true, out: file)
87
+ file.rewind
88
+ yield file
89
+ end
90
+ end
91
+
92
+ # Alias for {#cat_file_contents}; retained for backward compatibility
93
+ #
94
+ # @see #cat_file_contents
95
+ alias cat_file cat_file_contents
96
+
97
+ # Alias for {#cat_file_contents}
98
+ #
99
+ # @deprecated Use {#cat_file_contents} instead
100
+ #
101
+ # @see #cat_file_contents
102
+ alias object_contents cat_file_contents
103
+
104
+ # Returns the size of a git object in bytes
105
+ #
106
+ # @example Get the size of a commit object
107
+ # repo.cat_file_size('HEAD') #=> 265
108
+ #
109
+ # @example Get the size of a blob by treeish path
110
+ # repo.cat_file_size('HEAD:README.md') #=> 14
111
+ #
112
+ # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
113
+ #
114
+ # @return [Integer] the object size in bytes
115
+ #
116
+ # @raise [ArgumentError] if `object` starts with a hyphen
117
+ #
118
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
119
+ #
120
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
121
+ #
122
+ def cat_file_size(object)
123
+ raise ArgumentError, "Invalid object: '#{object}'" if object&.start_with?('-')
124
+
125
+ Git::Commands::CatFile::Raw.new(@execution_context).call(object, s: true).stdout.chomp.to_i
126
+ end
127
+
128
+ # Alias for {#cat_file_size}
129
+ #
130
+ # @deprecated Use {#cat_file_size} instead
131
+ #
132
+ # @see #cat_file_size
133
+ alias object_size cat_file_size
134
+
135
+ # Returns the type of a git object
136
+ #
137
+ # @example Get the type of a commit reference
138
+ # repo.cat_file_type('HEAD') #=> "commit"
139
+ #
140
+ # @example Get the type of a blob via treeish path
141
+ # repo.cat_file_type('HEAD:README.md') #=> "blob"
142
+ #
143
+ # @param object [String] the object name (SHA, ref, `HEAD`, treeish path, etc.)
144
+ #
145
+ # @return [String] the object type — one of `"blob"`, `"commit"`,
146
+ # `"tag"`, or `"tree"`
147
+ #
148
+ # @raise [ArgumentError] if `object` starts with a hyphen
149
+ #
150
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
151
+ #
152
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
153
+ #
154
+ def cat_file_type(object)
155
+ raise ArgumentError, "Invalid object: '#{object}'" if object&.start_with?('-')
156
+
157
+ Git::Commands::CatFile::Raw.new(@execution_context).call(object, t: true).stdout.chomp
158
+ end
159
+
160
+ # Alias for {#cat_file_type}
161
+ #
162
+ # @deprecated Use {#cat_file_type} instead
163
+ #
164
+ # @see #cat_file_type
165
+ alias object_type cat_file_type
166
+
167
+ # Returns parsed commit data for the given git object
168
+ #
169
+ # @example Get commit data for HEAD
170
+ # repo.cat_file_commit('HEAD')
171
+ # # => {
172
+ # # 'sha' => 'HEAD',
173
+ # # 'tree' => 'def5678...',
174
+ # # 'parent' => ['ghi9012...'],
175
+ # # 'author' => 'A U Thor <author@example.com> 1234567890 +0000',
176
+ # # 'committer' => 'A U Thor <author@example.com> 1234567890 +0000',
177
+ # # 'message' => "Initial commit\n"
178
+ # # }
179
+ #
180
+ # @param object [String] the object name (SHA, ref, `HEAD`, etc.)
181
+ #
182
+ # @return [Hash] commit data
183
+ #
184
+ # String-keyed hash with the following keys:
185
+ #
186
+ # * `tree` — the tree SHA
187
+ # * `parent` — Array of parent SHAs (empty for the root commit)
188
+ # * `author` — author identity string and timestamp
189
+ # * `committer` — committer identity string and timestamp
190
+ # * `message` — the commit message (includes trailing newline)
191
+ # * `gpgsig` — the cryptographic signature (signed commits only)
192
+ # * `sha` — the `object` argument as passed by the caller
193
+ #
194
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
195
+ #
196
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
197
+ #
198
+ def cat_file_commit(object)
199
+ result = Git::Commands::CatFile::Raw.new(@execution_context).call('commit', object)
200
+ Git::Parsers::CatFile.parse_commit(result.stdout.split("\n"), object)
201
+ end
202
+
203
+ # Alias for {#cat_file_commit}
204
+ #
205
+ # @deprecated Use {#cat_file_commit} instead
206
+ #
207
+ # @see #cat_file_commit
208
+ alias commit_data cat_file_commit
209
+
210
+ # Returns parsed tag data for the given annotated tag object
211
+ #
212
+ # Does not work with lightweight tags. To list all annotated tags in a
213
+ # repository:
214
+ #
215
+ # ```sh
216
+ # git for-each-ref --format='%(refname:strip=2)' refs/tags | \
217
+ # while read tag; do
218
+ # git cat-file tag "$tag" >/dev/null 2>&1 && echo "$tag"
219
+ # done
220
+ # ```
221
+ #
222
+ # @example Get tag data for an annotated tag
223
+ # repo.cat_file_tag('v1.0')
224
+ # # => {
225
+ # # 'name' => 'v1.0',
226
+ # # 'object' => 'abc1234...',
227
+ # # 'type' => 'commit',
228
+ # # 'tag' => 'v1.0',
229
+ # # 'tagger' => 'A U Thor <author@example.com> 1234567890 +0000',
230
+ # # 'message' => "Release v1.0\n"
231
+ # # }
232
+ #
233
+ # @param object [String] the annotated tag name or SHA
234
+ #
235
+ # @return [Hash] tag data
236
+ #
237
+ # String-keyed hash with the following keys:
238
+ #
239
+ # * `name` — the `object` argument as passed by the caller
240
+ # * `object` — the SHA of the tagged object
241
+ # * `type` — the type of the tagged object (usually `"commit"`)
242
+ # * `tag` — the tag name
243
+ # * `tagger` — tagger identity string and timestamp
244
+ # * `message` — the tag message (includes trailing newline)
245
+ #
246
+ # @raise [ArgumentError] if `object` starts with a hyphen
247
+ #
248
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
249
+ #
250
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
251
+ #
252
+ def cat_file_tag(object)
253
+ raise ArgumentError, "Invalid object: '#{object}'" if object&.start_with?('-')
254
+
255
+ tdata = Git::Commands::CatFile::Raw.new(@execution_context).call('tag', object).stdout.split("\n")
256
+ Git::Parsers::CatFile.parse_tag(tdata, object)
257
+ end
258
+
259
+ # Alias for {#cat_file_tag}
260
+ #
261
+ # @deprecated Use {#cat_file_tag} instead
262
+ #
263
+ # @see #cat_file_tag
264
+ alias tag_data cat_file_tag
265
+
266
+ # Resolve a revision specifier to its full object ID
267
+ #
268
+ # Passes the given revision specifier to `git rev-parse` and returns the
269
+ # full object ID.
270
+ #
271
+ # @example Resolve HEAD to its full object ID
272
+ # repo.rev_parse('HEAD') #=> "9b9b31e704c0b85ffdd8d2af2ded85170a5af87d"
273
+ #
274
+ # @example Resolve an abbreviated SHA
275
+ # repo.rev_parse('9b9b31e') #=> "9b9b31e704c0b85ffdd8d2af2ded85170a5af87d"
276
+ #
277
+ # @example Resolve a tree object via rev-parse syntax
278
+ # repo.rev_parse('HEAD^{tree}') #=> "94c827875e2cadb8bc8d4cdd900f19aa9e8634c7"
279
+ #
280
+ # @param objectish [String] the revision specifier to resolve (branch name,
281
+ # tag, abbreviated SHA, refspec, etc.)
282
+ #
283
+ # @return [String] the full object ID of the resolved object
284
+ #
285
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
286
+ #
287
+ # @see https://git-scm.com/docs/git-rev-parse git-rev-parse documentation
288
+ #
289
+ # @see https://git-scm.com/docs/git-rev-parse#_specifying_revisions Valid ways to specify revisions
290
+ #
291
+ def rev_parse(objectish)
292
+ Git::Commands::RevParse.new(@execution_context).call(objectish, '--', revs_only: true).stdout
293
+ end
294
+
295
+ alias revparse rev_parse
296
+
297
+ # Returns the SHA of a named tag
298
+ #
299
+ # Returns an empty string when the tag does not exist.
300
+ #
301
+ # @example Get the SHA of an existing tag
302
+ # repo.tag_sha('v1.0')
303
+ # #=> "abc1234567890abcdef1234567890abcdef123456"
304
+ #
305
+ # @example Get the SHA of a non-existent tag
306
+ # repo.tag_sha('nonexistent') #=> ""
307
+ #
308
+ # @param tag_name [String] the tag name to look up
309
+ #
310
+ # @return [String] the SHA of the named tag, or an empty string if the
311
+ # tag does not exist
312
+ #
313
+ # @see https://git-scm.com/docs/git-show-ref git-show-ref documentation
314
+ #
315
+ def tag_sha(tag_name)
316
+ tags_dir = File.expand_path(File.join(@execution_context.git_dir, 'refs', 'tags'))
317
+ head = File.expand_path(File.join(tags_dir, tag_name))
318
+ return File.read(head).chomp if head.start_with?("#{tags_dir}#{File::SEPARATOR}") && File.file?(head)
319
+
320
+ Private.show_ref_tag_sha(@execution_context, tag_name)
321
+ end
322
+
323
+ # Returns all recursive entries for a given tree object
324
+ #
325
+ # Equivalent to running `git ls-tree -r <objectish>` and splitting the
326
+ # output on newlines. Each returned line describes a single entry in the
327
+ # tree in the format produced by `git ls-tree`: `<mode> <type> <object>\t<file>`.
328
+ #
329
+ # @example List all files in the tree rooted at HEAD
330
+ # repo.full_tree('HEAD^{tree}')
331
+ # # => [
332
+ # # "100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\tex_dir/ex.txt",
333
+ # # "100644 blob abc1234...\tlib/git.rb"
334
+ # # ]
335
+ #
336
+ # @param objectish [String] the tree SHA or tree-ish specifier to recurse
337
+ # into
338
+ #
339
+ # @return [Array<String>] one entry per path, in the format
340
+ # `<mode> <type> <object>\t<file>`
341
+ #
342
+ # Returns an empty array for an empty tree.
343
+ #
344
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
345
+ #
346
+ # @see https://git-scm.com/docs/git-ls-tree git-ls-tree documentation
347
+ #
348
+ def full_tree(objectish)
349
+ Git::Commands::LsTree.new(@execution_context).call(objectish, r: true).stdout.split("\n")
350
+ end
351
+
352
+ # Returns the number of entries in a tree
353
+ #
354
+ # Runs `git ls-tree -r <objectish>` and counts output lines.
355
+ # This matches `Git::Lib#tree_depth` behavior in the 4.x branch.
356
+ #
357
+ # @example Count entries in the tree rooted at HEAD
358
+ # repo.tree_depth('HEAD^{tree}') #=> 42
359
+ #
360
+ # @param objectish [String] the tree SHA or tree-ish specifier to recurse
361
+ # into
362
+ #
363
+ # @return [Integer] the number of entries in the recursive tree listing
364
+ #
365
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
366
+ #
367
+ # @see https://git-scm.com/docs/git-ls-tree git-ls-tree documentation
368
+ #
369
+ def tree_depth(objectish)
370
+ Git::Commands::LsTree.new(@execution_context).call(objectish, r: true).stdout.each_line.count
371
+ end
372
+
373
+ # Find the first symbolic name for a commit-ish
374
+ #
375
+ # @example Find the symbolic name for a commit
376
+ # repo.name_rev('abc123') #=> "main~5"
377
+ #
378
+ # @example Find the symbolic name for HEAD
379
+ # repo.name_rev('HEAD') #=> "main"
380
+ #
381
+ # @param commit_ish [String] the commit-ish to find the symbolic name of
382
+ #
383
+ # @return [String, nil] the first symbolic name, or nil if stdout contains
384
+ # fewer than two words
385
+ #
386
+ # @raise [ArgumentError] if commit_ish starts with a hyphen
387
+ #
388
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
389
+ #
390
+ # @see https://git-scm.com/docs/git-name-rev git-name-rev documentation
391
+ #
392
+ def name_rev(commit_ish)
393
+ raise ArgumentError, "Invalid commit_ish: '#{commit_ish}'" if commit_ish&.start_with?('-')
394
+
395
+ Git::Commands::NameRev.new(@execution_context).call(commit_ish).stdout.split[1]
396
+ end
397
+
398
+ # Alias for {#name_rev}
399
+ #
400
+ # @deprecated Use {#name_rev} instead
401
+ #
402
+ # @see #name_rev
403
+ alias namerev name_rev
404
+
405
+ # Option keys accepted by {#ls_tree}
406
+ LS_TREE_ALLOWED_OPTS = %i[recursive path].freeze
407
+ private_constant :LS_TREE_ALLOWED_OPTS
408
+
409
+ # List the objects in a git tree
410
+ #
411
+ # Runs `git ls-tree` against the given sha and returns a Hash of tree
412
+ # entries organised by object type.
413
+ #
414
+ # @example List the top-level tree
415
+ # repo.ls_tree('HEAD')
416
+ # # => { 'blob' => { 'README.md' => { mode: '100644', sha: 'abc...' } },
417
+ # # 'tree' => { 'lib' => { mode: '040000', sha: 'def...' } },
418
+ # # 'commit' => {} }
419
+ #
420
+ # @example List the tree recursively
421
+ # repo.ls_tree('HEAD', recursive: true)
422
+ # # => { 'blob' => { 'lib/git.rb' => { mode: '100644', sha: '...' } }, ... }
423
+ #
424
+ # @example Limit the listing to a path
425
+ # repo.ls_tree('HEAD', path: 'lib/')
426
+ #
427
+ # @param objectish [String] the tree-ish object to list
428
+ #
429
+ # @param opts [Hash] additional options
430
+ #
431
+ # @option opts [Boolean, nil] :recursive (nil) recurse into subtrees
432
+ #
433
+ # @option opts [String, Array<String>] :path (nil) path or array of paths
434
+ # to limit the listing to
435
+ #
436
+ # @return [Hash<String, Hash<String, Hash>>] a three-level Hash keyed by
437
+ # object type (`'blob'`, `'tree'`, `'commit'`), then by filename, then
438
+ # holding `:mode` and `:sha` values
439
+ #
440
+ # @raise [ArgumentError] when unsupported options are provided
441
+ #
442
+ # @raise [Git::FailedError] when git exits with a non-zero exit status
443
+ #
444
+ # @see https://git-scm.com/docs/git-ls-tree git-ls-tree documentation
445
+ #
446
+ def ls_tree(objectish, opts = {})
447
+ SharedPrivate.assert_valid_opts!(LS_TREE_ALLOWED_OPTS, **opts)
448
+ paths = Array(opts[:path]).compact
449
+ r_value = opts[:recursive]
450
+ safe_options = {}
451
+ safe_options[:r] = r_value unless r_value.nil?
452
+ result = Git::Commands::LsTree.new(@execution_context).call(objectish, *paths, **safe_options)
453
+ Git::Parsers::LsTree.parse(result.stdout)
454
+ end
455
+
456
+ # Option keys accepted by {#grep}
457
+ GREP_ALLOWED_OPTS = %i[ignore_case i invert_match v extended_regexp E object].freeze
458
+ private_constant :GREP_ALLOWED_OPTS
459
+
460
+ # Search tracked file contents in a git tree for a pattern
461
+ #
462
+ # Runs `git grep` against the given tree-ish and returns every match as a
463
+ # filename-keyed hash of `[line_number, text]` pairs.
464
+ #
465
+ # @example Search HEAD for a pattern
466
+ # repo.grep('TODO')
467
+ # # => { "HEAD:src/foo.rb" => [[12, "# TODO: fix this"]], ... }
468
+ #
469
+ # @example Limit the search to a path
470
+ # repo.grep('TODO', 'src/')
471
+ #
472
+ # @example Search a specific commit
473
+ # repo.grep('TODO', nil, object: 'abc1234')
474
+ #
475
+ # @example Case-insensitive search
476
+ # repo.grep('todo', nil, ignore_case: true)
477
+ #
478
+ # @param pattern [String] the pattern to search for
479
+ #
480
+ # @param path_limiter [String, Pathname, Array<String, Pathname>, nil]
481
+ # a path or array of paths to limit the search to, or `nil` for no limit
482
+ #
483
+ # @param opts [Hash] additional options for the grep command
484
+ #
485
+ # @option opts [String] :object ('HEAD') the tree-ish to search
486
+ #
487
+ # @option opts [Boolean, nil] :ignore_case (nil) ignore case
488
+ # distinctions in both the pattern and the file contents
489
+ #
490
+ # Alias: :i
491
+ #
492
+ # @option opts [Boolean, nil] :invert_match (nil) select non-matching
493
+ # lines
494
+ #
495
+ # Alias: :v
496
+ #
497
+ # @option opts [Boolean, nil] :extended_regexp (nil) use POSIX extended
498
+ # regular expressions for the pattern
499
+ #
500
+ # Alias: :E
501
+ #
502
+ # @return [Hash<String, Array<Array(Integer, String)>>] a hash mapping
503
+ # each `"treeish:filename"` key to an array of `[line_number, text]`
504
+ # pairs; returns an empty hash when no lines match
505
+ #
506
+ # @raise [ArgumentError] if unsupported options are provided
507
+ #
508
+ # @raise [Git::FailedError] if git exits with a non-zero status and
509
+ # stderr is non-empty (e.g. bad object reference)
510
+ #
511
+ # @see https://git-scm.com/docs/git-grep git-grep documentation
512
+ #
513
+ def grep(pattern, path_limiter = nil, opts = {})
514
+ SharedPrivate.assert_valid_opts!(GREP_ALLOWED_OPTS, **opts)
515
+ opts = opts.dup
516
+ object = opts.delete(:object) || 'HEAD'
517
+ opts[:pathspec] = Array(path_limiter).map(&:to_s) if path_limiter
518
+ result = Git::Commands::Grep.new(@execution_context).call(
519
+ object, pattern:, **opts, no_color: true, line_number: true, null: true
520
+ )
521
+ Private.parse_grep_result(result)
522
+ end
523
+
524
+ # Option keys accepted by {#archive}
525
+ ARCHIVE_ALLOWED_OPTS = %i[prefix remote path format add_gzip].freeze
526
+ private_constant :ARCHIVE_ALLOWED_OPTS
527
+
528
+ # Create an archive of the repository tree and write it to a file
529
+ #
530
+ # Writes the archive content to a file and returns the file path. The
531
+ # default format is `zip`. Pass `format: 'tar'` for an uncompressed tar
532
+ # archive, or `format: 'tgz'` for a gzip-compressed tar archive
533
+ # (equivalent to `format: 'tar'` with `add_gzip: true`).
534
+ #
535
+ # When no `file` path is given, a temporary file is created and its path
536
+ # is returned.
537
+ #
538
+ # **File replacement behavior when `file` is given:**
539
+ #
540
+ # The archive is first written to a staging file in the same directory as
541
+ # `file`. This means write permission is required on the parent directory
542
+ # of `file`, not just on `file` itself. Once the archive is fully written,
543
+ # the staging file atomically replaces `file` via rename.
544
+ #
545
+ # If `file` already exists, only its numeric permission bits are applied to
546
+ # the new archive; ownership, ACLs, and extended attributes are not
547
+ # transferred. If `file` does not exist, the archive receives the standard
548
+ # file creation mode (`0666 & ~umask`). On Windows, `File.chmod` has no
549
+ # effect, so the archive always receives the default creation mode
550
+ # regardless of whether `file` already exists.
551
+ #
552
+ # If `file` is a symlink that does not point to a directory, the symlink
553
+ # itself is replaced by the new archive file rather than writing through
554
+ # the link to its target. A symlink that points to a directory is treated
555
+ # as a directory and rejected with `ArgumentError`.
556
+ #
557
+ # @example Archive HEAD as a zip file
558
+ # repo.archive('HEAD', '/tmp/release.zip') #=> "/tmp/release.zip"
559
+ #
560
+ # @example Archive a tag as a tar file
561
+ # repo.archive('v1.0', '/tmp/release.tar', format: 'tar') #=> "/tmp/release.tar"
562
+ #
563
+ # @example Archive with a path prefix applied to every entry
564
+ # repo.archive('HEAD', '/tmp/out.tar', format: 'tar', prefix: 'myproject/')
565
+ # #=> "/tmp/out.tar"
566
+ #
567
+ # @example Archive a subdirectory only
568
+ # repo.archive('HEAD', '/tmp/src.tar', format: 'tar', path: 'src/')
569
+ # #=> "/tmp/src.tar"
570
+ #
571
+ # @param treeish [String] tree-ish to archive — commit SHA, tag, branch
572
+ # name, or tree SHA
573
+ #
574
+ # @param file [String, nil] (nil) destination file path; when `nil`, a
575
+ # unique temporary file is created and its path is returned
576
+ #
577
+ # @param opts [Hash] archive options
578
+ #
579
+ # @option opts [String] :format ('zip') archive format — `'tar'`, `'zip'`,
580
+ # or `'tgz'`; `'tgz'` is internally converted to `'tar'` with gzip
581
+ # post-processing
582
+ #
583
+ # @option opts [String] :prefix (nil) prefix prepended to every filename
584
+ # in the archive; typically ends with `/`
585
+ #
586
+ # @option opts [String] :path (nil) path within the tree to include in the
587
+ # archive; when given, only files under that path are archived
588
+ #
589
+ # @option opts [String] :remote (nil) retrieve the archive from a remote
590
+ # repository rather than the local one
591
+ #
592
+ # @option opts [Boolean, nil] :add_gzip (nil) apply gzip compression after
593
+ # writing the archive; set automatically when `format: 'tgz'` is given
594
+ #
595
+ # @return [String] path to the written archive file
596
+ #
597
+ # @raise [ArgumentError] if unsupported options are provided
598
+ #
599
+ # @raise [ArgumentError] if `file` is an existing directory
600
+ #
601
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
602
+ #
603
+ # @see https://git-scm.com/docs/git-archive git-archive documentation
604
+ #
605
+ def archive(treeish, file = nil, opts = {})
606
+ SharedPrivate.assert_valid_opts!(ARCHIVE_ALLOWED_OPTS, **opts)
607
+ raise ArgumentError, "#{file.inspect} is a directory" if file && File.directory?(file)
608
+
609
+ tmp = Private.write_archive_tmp(@execution_context, treeish, opts, dest_dir: Private.staging_dir_for(file))
610
+ return tmp unless file
611
+
612
+ Private.atomic_replace(tmp, file)
613
+ file
614
+ rescue StandardError
615
+ FileUtils.rm_f(tmp) if tmp
616
+ raise
617
+ end
618
+
619
+ # Returns a blob object for the given object reference
620
+ #
621
+ # The returned object is lazy: no git command is invoked until a property
622
+ # (e.g. {Git::Object::AbstractObject#sha}, {Git::Object::AbstractObject#contents})
623
+ # is accessed on the result.
624
+ #
625
+ # @example Get a blob from a treeish path
626
+ # repo.gblob('HEAD:README.md')
627
+ # #=> #<Git::Object::Blob ...>
628
+ #
629
+ # @param objectish [String] the object name (SHA, treeish path, ref, etc.)
630
+ #
631
+ # @return [Git::Object::Blob] the blob object
632
+ #
633
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
634
+ #
635
+ def gblob(objectish)
636
+ Git::Object.new(self, objectish, 'blob')
637
+ end
638
+
639
+ # Returns a commit object for the given object reference
640
+ #
641
+ # The returned object is lazy: no git command is invoked until a property
642
+ # (e.g. {Git::Object::AbstractObject#sha}, {Git::Object::Commit#message})
643
+ # is accessed on the result.
644
+ #
645
+ # @example Get a commit by symbolic ref
646
+ # repo.gcommit('HEAD')
647
+ # #=> #<Git::Object::Commit ...>
648
+ #
649
+ # @example Get a commit by abbreviated SHA
650
+ # repo.gcommit('abc1234')
651
+ # #=> #<Git::Object::Commit ...>
652
+ #
653
+ # @param objectish [String] the object name (SHA, branch, tag, refspec, etc.)
654
+ #
655
+ # @return [Git::Object::Commit] the commit object
656
+ #
657
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
658
+ #
659
+ def gcommit(objectish)
660
+ Git::Object.new(self, objectish, 'commit')
661
+ end
662
+
663
+ # Returns a tree object for the given object reference
664
+ #
665
+ # The returned object is lazy: no git command is invoked until a property
666
+ # (e.g. {Git::Object::AbstractObject#sha}, {Git::Object::Tree#children})
667
+ # is accessed on the result.
668
+ #
669
+ # @example Get the root tree for the current HEAD
670
+ # repo.gtree('HEAD^{tree}')
671
+ # #=> #<Git::Object::Tree ...>
672
+ #
673
+ # @param objectish [String] the object name (SHA, treeish specifier, etc.)
674
+ #
675
+ # @return [Git::Object::Tree] the tree object
676
+ #
677
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
678
+ #
679
+ def gtree(objectish)
680
+ Git::Object.new(self, objectish, 'tree')
681
+ end
682
+
683
+ # Returns a tag object for the given tag name
684
+ #
685
+ # Returns a {Git::Object::Tag} for `tag_name`. The returned object is
686
+ # either an annotated or a lightweight tag depending on the underlying
687
+ # ref type.
688
+ #
689
+ # @example Get a tag object
690
+ # repo.tag('v1.0')
691
+ # #=> #<Git::Object::Tag name="v1.0" ...>
692
+ #
693
+ # @param tag_name [String] the name of the tag
694
+ #
695
+ # @return [Git::Object::Tag] the tag object
696
+ #
697
+ # @raise [Git::UnexpectedResultError] if `tag_name` does not name an
698
+ # existing tag
699
+ #
700
+ # @raise [Git::FailedError] if the underlying `git show-ref` invocation
701
+ # exits with an unexpected status (i.e., outside the allowed 0..1 range)
702
+ #
703
+ def tag(tag_name)
704
+ Git::Object::Tag.new(self, tag_name)
705
+ end
706
+
707
+ # Returns the appropriate git object for the given object reference
708
+ #
709
+ # Runs `git cat-file -t` to determine the object type, then constructs
710
+ # and returns the corresponding `Git::Object::*` subclass instance.
711
+ #
712
+ # @example Get a commit object from HEAD
713
+ # repo.object('HEAD')
714
+ # #=> #<Git::Object::Commit ...>
715
+ #
716
+ # @example Get a blob from a treeish path
717
+ # repo.object('HEAD:README.md')
718
+ # #=> #<Git::Object::Blob ...>
719
+ #
720
+ # @param objectish [String] the object name (SHA, ref, treeish path, etc.)
721
+ #
722
+ # @return [Git::Object::Blob, Git::Object::Commit, Git::Object::Tree] the
723
+ # git object for the given reference
724
+ #
725
+ # @raise [ArgumentError] if `objectish` starts with a hyphen
726
+ #
727
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
728
+ #
729
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
730
+ #
731
+ def object(objectish)
732
+ Git::Object.new(self, objectish)
733
+ end
734
+
735
+ # Returns all tags in the repository as tag objects
736
+ #
737
+ # Runs `git tag --list` with a machine-readable format, parses the output,
738
+ # and returns a {Git::Object::Tag} for each tag name.
739
+ #
740
+ # @example List the names of all tags
741
+ # repo.tags.map(&:name) #=> ["v1.0.0", "v2.0.0"]
742
+ #
743
+ # @example No tags exist
744
+ # repo.tags #=> []
745
+ #
746
+ # @return [Array<Git::Object::Tag>] one tag object per tag in the
747
+ # repository; empty when there are none
748
+ #
749
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
750
+ #
751
+ def tags
752
+ result = Git::Commands::Tag::List.new(@execution_context).call(format: Git::Parsers::Tag::FORMAT_STRING)
753
+ Git::Parsers::Tag.parse_list(result.stdout).map { |info| tag(info.name) }
754
+ end
755
+
756
+ # Option keys accepted by {#tag_add}
757
+ TAG_ADD_ALLOWED_OPTS = %i[
758
+ annotate a sign s no_sign local_user u force f message m file F
759
+ edit e no_edit trailer cleanup create_reflog
760
+ ].freeze
761
+ private_constant :TAG_ADD_ALLOWED_OPTS
762
+
763
+ # Create a new tag
764
+ #
765
+ # @overload tag_add(name, options = {})
766
+ #
767
+ # @example Create a lightweight tag on HEAD
768
+ # repo.tag_add('v1.0.0')
769
+ #
770
+ # @example Create an annotated tag on HEAD
771
+ # repo.tag_add('v1.0.0', annotate: true, message: 'Release 1.0.0')
772
+ #
773
+ # @example Replace an existing tag on HEAD
774
+ # repo.tag_add('v1.0.0', force: true)
775
+ #
776
+ # @param name [String] the name of the tag to create
777
+ #
778
+ # @param options [Hash] options for creating the tag
779
+ #
780
+ # @option options [Boolean, nil] :annotate (nil) make an unsigned,
781
+ # annotated tag object; requires `:message` or `:file` (alias: `:a`)
782
+ #
783
+ # @option options [Boolean, nil] :a (nil) alias for `:annotate`
784
+ #
785
+ # @option options [Boolean, nil] :sign (nil) make a GPG-signed tag;
786
+ # requires `:message` or `:file` (alias: `:s`)
787
+ #
788
+ # @option options [Boolean, nil] :s (nil) alias for `:sign`
789
+ #
790
+ # @option options [Boolean, nil] :no_sign (nil) override `tag.gpgSign`
791
+ # config to disable signing
792
+ #
793
+ # @option options [String] :local_user (nil) make a signed tag using the
794
+ # given key (alias: `:u`)
795
+ #
796
+ # @option options [String] :u (nil) alias for `:local_user`
797
+ #
798
+ # @option options [Boolean, nil] :force (nil) replace an existing tag with
799
+ # the given name instead of failing (alias: `:f`)
800
+ #
801
+ # @option options [Boolean, nil] :f (nil) alias for `:force`
802
+ #
803
+ # @option options [String] :message (nil) use the given message as the tag
804
+ # message (alias: `:m`)
805
+ #
806
+ # @option options [String] :m (nil) alias for `:message`
807
+ #
808
+ # @option options [String] :file (nil) take the tag message from the given
809
+ # file; use `-` to read from standard input (alias: `:F`)
810
+ #
811
+ # @option options [String] :F (nil) alias for `:file`
812
+ #
813
+ # @option options [Boolean, nil] :edit (nil) open an editor to further edit
814
+ # the tag message (alias: `:e`)
815
+ #
816
+ # @option options [Boolean, nil] :e (nil) alias for `:edit`
817
+ #
818
+ # @option options [Boolean, nil] :no_edit (nil) suppress the editor
819
+ #
820
+ # @option options [Hash, Array<Array>] :trailer (nil) add trailers to the
821
+ # tag message
822
+ #
823
+ # @option options [String] :cleanup (nil) set how the tag message is
824
+ # cleaned up; one of `verbatim`, `whitespace`, or `strip`
825
+ #
826
+ # @option options [Boolean, nil] :create_reflog (nil) create a reflog for
827
+ # the tag
828
+ #
829
+ # @return [Git::Object::Tag] the newly created tag
830
+ #
831
+ # @overload tag_add(name, target, options = {})
832
+ #
833
+ # @example Create a lightweight tag on a specific commit
834
+ # repo.tag_add('v1.0.0', 'abc123')
835
+ #
836
+ # @example Create an annotated tag on a specific commit
837
+ # repo.tag_add('v1.0.0', 'abc123', annotate: true, message: 'Release 1.0.0')
838
+ #
839
+ # @param name [String] the name of the tag to create
840
+ #
841
+ # @param target [String] the object to tag (commit SHA, branch name, etc.)
842
+ #
843
+ # @param options [Hash] options for creating the tag (same keys as the
844
+ # first overload)
845
+ #
846
+ # @return [Git::Object::Tag] the newly created tag
847
+ #
848
+ # @overload tag_add(name, delete_options)
849
+ #
850
+ # @deprecated Use {#tag_delete} instead.
851
+ #
852
+ # @example Delete a tag (deprecated)
853
+ # repo.tag_add('v1.0.0', d: true)
854
+ #
855
+ # @param name [String] the name of the tag to delete
856
+ #
857
+ # @param delete_options [{ d: true }, { delete: true }] deletion options;
858
+ # only `:d` or `:delete` (set to `true`) is accepted — no other keys
859
+ # and no `target` argument may be combined with this form
860
+ #
861
+ # @return [String] git's stdout from the delete
862
+ #
863
+ # @raise [ArgumentError] if a target is also provided
864
+ #
865
+ # @raise [ArgumentError] if options other than `:d`/`:delete` are also
866
+ # provided
867
+ #
868
+ # @raise [ArgumentError] if unsupported options are provided
869
+ #
870
+ # @raise [ArgumentError] if an annotated or signed tag is requested without
871
+ # a message
872
+ #
873
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
874
+ #
875
+ def tag_add(name, *args)
876
+ options = args.last.is_a?(Hash) ? args.pop : {}
877
+ target = args.first
878
+
879
+ return Private.tag_add_delete_deprecated(self, name, target, options) if options[:d] || options[:delete]
880
+
881
+ options = options.except(:d, :delete)
882
+ SharedPrivate.assert_valid_opts!(TAG_ADD_ALLOWED_OPTS, **options)
883
+ Private.validate_tag_options!(options)
884
+ Git::Commands::Tag::Create.new(@execution_context).call(name, target, **options)
885
+ tag(name)
886
+ end
887
+
888
+ # @deprecated Use {#tag_add} instead.
889
+ #
890
+ # @overload add_tag(name, options = {})
891
+ #
892
+ # @param name [String] the name of the tag to create
893
+ #
894
+ # @param options [Hash] options for creating the tag
895
+ #
896
+ # @return [Git::Object::Tag] the newly created tag
897
+ #
898
+ # @overload add_tag(name, target, options = {})
899
+ #
900
+ # @param name [String] the name of the tag to create
901
+ #
902
+ # @param target [String] the object to tag (commit SHA, branch name, etc.)
903
+ #
904
+ # @param options [Hash] options for creating the tag
905
+ #
906
+ # @return [Git::Object::Tag] the newly created tag
907
+ #
908
+ # @raise [ArgumentError] if unsupported options are provided
909
+ #
910
+ # @raise [ArgumentError] if an annotated or signed tag is requested without
911
+ # a message
912
+ #
913
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
914
+ #
915
+ def add_tag(name, *options)
916
+ Git::Deprecation.warn(
917
+ 'Git::Repository#add_tag is deprecated and will be removed in v6.0.0. ' \
918
+ 'Use Git::Repository#tag_add instead.'
919
+ )
920
+ tag_add(name, *options)
921
+ end
922
+
923
+ # Delete a tag
924
+ #
925
+ # @example Delete a tag
926
+ # repo.tag_delete('v1.0.0')
927
+ #
928
+ # @param name [String] the name of the tag to delete
929
+ #
930
+ # @return [String] git's stdout from the delete
931
+ #
932
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
933
+ #
934
+ def tag_delete(name)
935
+ result = Git::Commands::Tag::Delete.new(@execution_context).call(name)
936
+ raise Git::FailedError, result if result.status.exitstatus.positive?
937
+
938
+ result.stdout
939
+ end
940
+
941
+ # @deprecated Use {#tag_delete} instead.
942
+ #
943
+ # @param name [String] the name of the tag to delete
944
+ #
945
+ # @return [String] git's stdout from the delete
946
+ #
947
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
948
+ #
949
+ def delete_tag(name)
950
+ Git::Deprecation.warn(
951
+ 'Git::Repository#delete_tag is deprecated and will be removed in v6.0.0. ' \
952
+ 'Use Git::Repository#tag_delete instead.'
953
+ )
954
+ tag_delete(name)
955
+ end
956
+
957
+ # Private helpers
958
+ #
959
+ # @api private
960
+ #
961
+ module Private
962
+ module_function
963
+
964
+ # Validate that a message is present when an annotated or signed tag is
965
+ # requested
966
+ #
967
+ # @param opts [Hash] the tag-creation options
968
+ #
969
+ # @return [void]
970
+ #
971
+ # @raise [ArgumentError] when an annotated or signed tag is requested
972
+ # without a `:message`/`:m`/`:file`/`:F` value
973
+ #
974
+ def validate_tag_options!(opts)
975
+ needs_message = %i[a annotate s sign u local_user].any? { |k| opts[k] }
976
+ has_message = opts[:m] || opts[:message] || opts[:F] || opts[:file]
977
+
978
+ return unless needs_message && !has_message
979
+
980
+ raise ArgumentError, 'Cannot create an annotated or signed tag without a message.'
981
+ end
982
+
983
+ # Handle the deprecated :d/:delete option on tag_add
984
+ #
985
+ # Issues a deprecation warning and delegates to tag_delete. Raises
986
+ # ArgumentError if a target or incompatible options are also supplied.
987
+ #
988
+ # @param facade [ObjectOperations] the calling facade instance
989
+ # @param name [String] tag name
990
+ # @param target [String, nil] target argument (must be nil)
991
+ # @param opts [Hash] options hash (must contain only :d/:delete)
992
+ #
993
+ # @return [String] stdout from tag_delete
994
+ #
995
+ # @api private
996
+ #
997
+ def tag_add_delete_deprecated(facade, name, target, opts)
998
+ Git::Deprecation.warn(
999
+ 'Passing :d or :delete to tag_add is deprecated and will be removed in v6.0.0. ' \
1000
+ 'Use tag_delete instead.'
1001
+ )
1002
+ raise ArgumentError, 'Cannot pass a target when using the :d/:delete option.' if target
1003
+
1004
+ extra = opts.keys - %i[d delete]
1005
+ raise ArgumentError, "Cannot combine :d/:delete with other options: #{extra.join(', ')}" unless extra.empty?
1006
+
1007
+ facade.tag_delete(name)
1008
+ end
1009
+
1010
+ def show_ref_tag_sha(execution_context, tag_name)
1011
+ ref = "refs/tags/#{tag_name}"
1012
+ result = Git::Commands::ShowRef::List.new(execution_context).call(ref)
1013
+ return '' if result.status.exitstatus == 1
1014
+
1015
+ line = result.stdout.lines.find { |l| l.split[1] == ref }
1016
+ line ? line.split[0] : ''
1017
+ end
1018
+
1019
+ def parse_grep_result(result)
1020
+ exitstatus = result.status.exitstatus
1021
+ return {} if exitstatus == 1 && result.stderr.empty?
1022
+ raise Git::FailedError, result if exitstatus == 1
1023
+
1024
+ Git::Parsers::Grep.parse(result.stdout)
1025
+ end
1026
+
1027
+ # Resolve the staging directory for a git archive temp file
1028
+ #
1029
+ # Always returns `Dir.tmpdir` when `file` is nil, or the parent
1030
+ # directory of `file` otherwise. Staging the temp file in the same
1031
+ # directory as the destination keeps both paths on the same filesystem
1032
+ # so that {#atomic_replace} can use an atomic rename that
1033
+ # requires no extra disk space.
1034
+ #
1035
+ # @param file [String, nil] the explicit destination path, or nil
1036
+ #
1037
+ # @return [String] directory path to pass to `Tempfile.create`
1038
+ #
1039
+ # @api private
1040
+ #
1041
+ def staging_dir_for(file)
1042
+ return Dir.tmpdir unless file
1043
+
1044
+ File.dirname(File.expand_path(file))
1045
+ end
1046
+
1047
+ # Write a git archive to a fresh temporary file and return its path
1048
+ #
1049
+ # Always writes to a new temporary file so that on error the caller's
1050
+ # destination file is never truncated. Format and gzip post-processing
1051
+ # are determined from `opts` via {#parse_archive_format_options}.
1052
+ #
1053
+ # @param execution_context [Git::ExecutionContext] for the git command
1054
+ # @param treeish [String] tree-ish passed to `git archive`
1055
+ # @param opts [Hash] caller-supplied options (read-only)
1056
+ # @param dest_dir [String] directory for the staging temp file; use
1057
+ # {#staging_dir_for} to select the optimal directory for the destination
1058
+ #
1059
+ # @return [String] path to the populated temporary file
1060
+ #
1061
+ # @api private
1062
+ #
1063
+ def write_archive_tmp(execution_context, treeish, opts, dest_dir: Dir.tmpdir)
1064
+ format, gzip = parse_archive_format_options(opts)
1065
+ tmp_file = create_archive_tempfile(execution_context, treeish, opts, format, dest_dir)
1066
+ apply_gzip(tmp_file.path) if gzip
1067
+ tmp_file.path
1068
+ rescue StandardError
1069
+ tmp_file.close unless tmp_file.nil? || tmp_file.closed?
1070
+ FileUtils.rm_f(tmp_file.path) if tmp_file
1071
+ raise
1072
+ end
1073
+
1074
+ # Create a staging file, write the archive into it, close it, and return it
1075
+ #
1076
+ # Uses `Tempfile.create` (not `Tempfile.new`) so that no GC finalizer is
1077
+ # registered on the returned object — the file path remains valid after this
1078
+ # method returns and after the caller stores only the path string.
1079
+ #
1080
+ # @param execution_context [Git::ExecutionContext] for the git command
1081
+ # @param treeish [String] tree-ish passed to `git archive`
1082
+ # @param opts [Hash] caller-supplied options (read-only; used for :prefix,
1083
+ # :remote, and :path)
1084
+ # @param format [String] archive format string (e.g. `'zip'` or `'tar'`)
1085
+ # @param dest_dir [String] directory in which to create the temp file
1086
+ #
1087
+ # @return [File] the closed file containing the archive
1088
+ #
1089
+ # @api private
1090
+ #
1091
+ def create_archive_tempfile(execution_context, treeish, opts, format, dest_dir)
1092
+ tmp_file = Tempfile.create('archive', dest_dir).tap(&:binmode)
1093
+ run_archive_command(execution_context, treeish, opts, format, tmp_file)
1094
+ tmp_file.close
1095
+ tmp_file
1096
+ rescue StandardError
1097
+ tmp_file&.close
1098
+ FileUtils.rm_f(tmp_file.path) if tmp_file
1099
+ raise
1100
+ end
1101
+
1102
+ # Invoke `git archive` and stream output into `tmp_file`
1103
+ #
1104
+ # @param execution_context [Git::ExecutionContext] for the git command
1105
+ # @param treeish [String] tree-ish passed to `git archive`
1106
+ # @param opts [Hash] caller-supplied options (read-only; used for :prefix,
1107
+ # :remote, and :path)
1108
+ # @param format [String] archive format to pass to `git archive --format`
1109
+ # @param tmp_file [File] open, binary-mode IO to write archive data to
1110
+ #
1111
+ # @return [Git::CommandLineResult] the result of the git command
1112
+ #
1113
+ # @api private
1114
+ #
1115
+ def run_archive_command(execution_context, treeish, opts, format, tmp_file)
1116
+ command_opts = opts.slice(:prefix, :remote).merge(format: format)
1117
+ path_args = opts[:path] ? [opts[:path]] : []
1118
+ Git::Commands::Archive.new(execution_context).call(treeish, *path_args, **command_opts, out: tmp_file)
1119
+ end
1120
+
1121
+ # Atomically rename the staging file `src` to `dest`, replacing any
1122
+ # existing file at `dest`. Both paths must be on the same filesystem
1123
+ # (guaranteed when `src` is created by {#staging_dir_for}).
1124
+ #
1125
+ # Before the rename, the staging file's permissions are set to the
1126
+ # existing file's numeric mode (if `dest` already existed) or to
1127
+ # `0666 & ~umask` (standard creation mode) for new files. The chmod
1128
+ # is applied to `src` before the rename so that, if chmod fails, `src`
1129
+ # is still present and can be cleaned up by the rescue. Only the
1130
+ # numeric permission bits are carried over; ownership, ACLs, and
1131
+ # extended attributes from an existing `dest` are not preserved.
1132
+ #
1133
+ # If `dest` is a symlink, the symlink itself is replaced by the renamed
1134
+ # staging file rather than writing through the link to its target.
1135
+ #
1136
+ # @param src [String] staging file path to rename; removed on success
1137
+ # @param dest [String] destination file path
1138
+ #
1139
+ # @return [void]
1140
+ #
1141
+ # @api private
1142
+ #
1143
+ def atomic_replace(src, dest)
1144
+ mode = File.exist?(dest) ? (File.stat(dest).mode & 0o777) : (0o666 & ~File.umask)
1145
+ File.chmod(mode, src)
1146
+ File.rename(src, dest)
1147
+ rescue StandardError
1148
+ FileUtils.rm_f(src)
1149
+ raise
1150
+ end
1151
+
1152
+ # Determine the archive format and whether to apply gzip post-processing
1153
+ #
1154
+ # The `tgz` pseudo-format is not understood by `git archive` directly;
1155
+ # it is converted to `tar` and the gzip flag is set so that {#archive}
1156
+ # applies gzip compression after the archive is written.
1157
+ #
1158
+ # @param opts [Hash] caller-supplied options hash (read-only)
1159
+ #
1160
+ # @return [Array(String, Boolean)] a two-element array `[format, gzip]`
1161
+ #
1162
+ # `format` is the string to pass to `git archive --format`; `gzip` is
1163
+ # `true` when the caller should apply gzip post-processing after writing
1164
+ # the archive.
1165
+ #
1166
+ # @api private
1167
+ #
1168
+ def parse_archive_format_options(opts)
1169
+ format = opts[:format] || 'zip'
1170
+ gzip = opts[:add_gzip] == true || format == 'tgz'
1171
+ [format == 'tgz' ? 'tar' : format, gzip]
1172
+ end
1173
+
1174
+ # Apply gzip compression to the given file in place
1175
+ #
1176
+ # Streams from the source file through a {Zlib::GzipWriter} into a sibling
1177
+ # temporary file, then replaces the original. Peak memory is proportional
1178
+ # to the stream buffer rather than the full archive size.
1179
+ #
1180
+ # @param file [String] path to the file to compress in place
1181
+ #
1182
+ # @return [void]
1183
+ #
1184
+ # @api private
1185
+ #
1186
+ def apply_gzip(file)
1187
+ gz_tmp = Tempfile.create('archive_gz', File.dirname(file)).tap(&:close).path
1188
+ Zlib::GzipWriter.open(gz_tmp) { |gz| File.open(file, 'rb') { |f| IO.copy_stream(f, gz) } }
1189
+ FileUtils.rm_f(file)
1190
+ File.rename(gz_tmp, file)
1191
+ rescue StandardError
1192
+ FileUtils.rm_f(gz_tmp) if gz_tmp
1193
+ raise
1194
+ end
1195
+ end
1196
+ private_constant :Private
1197
+ end
1198
+ end
1199
+ end