git 1.19.1 → 5.5.0

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 (265) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +5 -1
  3. data/AI_POLICY.md +24 -0
  4. data/CHANGELOG.md +689 -0
  5. data/CODE_OF_CONDUCT.md +25 -0
  6. data/CONTRIBUTING.md +1175 -97
  7. data/GOVERNANCE.md +106 -0
  8. data/LICENSE +1 -1
  9. data/MAINTAINERS.md +17 -4
  10. data/README.md +476 -320
  11. data/UPGRADING.md +1138 -0
  12. data/git.gemspec +124 -36
  13. data/lib/git/author.rb +39 -7
  14. data/lib/git/author_info.rb +66 -0
  15. data/lib/git/branch.rb +615 -65
  16. data/lib/git/branch_delete_failure.rb +34 -0
  17. data/lib/git/branch_delete_result.rb +66 -0
  18. data/lib/git/branch_info.rb +237 -0
  19. data/lib/git/branches.rb +167 -44
  20. data/lib/git/command_line/base.rb +247 -0
  21. data/lib/git/command_line/capturing.rb +308 -0
  22. data/lib/git/command_line/result.rb +88 -0
  23. data/lib/git/command_line/streaming.rb +236 -0
  24. data/lib/git/command_line.rb +52 -0
  25. data/lib/git/commands/add.rb +139 -0
  26. data/lib/git/commands/am/abort.rb +43 -0
  27. data/lib/git/commands/am/apply.rb +263 -0
  28. data/lib/git/commands/am/continue.rb +43 -0
  29. data/lib/git/commands/am/quit.rb +43 -0
  30. data/lib/git/commands/am/retry.rb +49 -0
  31. data/lib/git/commands/am/show_current_patch.rb +64 -0
  32. data/lib/git/commands/am/skip.rb +42 -0
  33. data/lib/git/commands/am.rb +33 -0
  34. data/lib/git/commands/apply.rb +242 -0
  35. data/lib/git/commands/archive/list_formats.rb +46 -0
  36. data/lib/git/commands/archive.rb +145 -0
  37. data/lib/git/commands/arguments.rb +4521 -0
  38. data/lib/git/commands/base.rb +502 -0
  39. data/lib/git/commands/branch/copy.rb +102 -0
  40. data/lib/git/commands/branch/create.rb +177 -0
  41. data/lib/git/commands/branch/delete.rb +88 -0
  42. data/lib/git/commands/branch/list.rb +178 -0
  43. data/lib/git/commands/branch/move.rb +102 -0
  44. data/lib/git/commands/branch/set_upstream.rb +86 -0
  45. data/lib/git/commands/branch/show_current.rb +49 -0
  46. data/lib/git/commands/branch/unset_upstream.rb +53 -0
  47. data/lib/git/commands/branch.rb +34 -0
  48. data/lib/git/commands/cat_file/batch.rb +385 -0
  49. data/lib/git/commands/cat_file/filtered.rb +105 -0
  50. data/lib/git/commands/cat_file/raw.rb +271 -0
  51. data/lib/git/commands/cat_file.rb +49 -0
  52. data/lib/git/commands/checkout/branch.rb +153 -0
  53. data/lib/git/commands/checkout/files.rb +116 -0
  54. data/lib/git/commands/checkout.rb +38 -0
  55. data/lib/git/commands/checkout_index.rb +106 -0
  56. data/lib/git/commands/clean.rb +102 -0
  57. data/lib/git/commands/clone.rb +241 -0
  58. data/lib/git/commands/commit.rb +273 -0
  59. data/lib/git/commands/commit_tree.rb +101 -0
  60. data/lib/git/commands/config_option_syntax/add.rb +86 -0
  61. data/lib/git/commands/config_option_syntax/get.rb +121 -0
  62. data/lib/git/commands/config_option_syntax/get_all.rb +118 -0
  63. data/lib/git/commands/config_option_syntax/get_color.rb +95 -0
  64. data/lib/git/commands/config_option_syntax/get_color_bool.rb +96 -0
  65. data/lib/git/commands/config_option_syntax/get_regexp.rb +119 -0
  66. data/lib/git/commands/config_option_syntax/get_urlmatch.rb +111 -0
  67. data/lib/git/commands/config_option_syntax/list.rb +111 -0
  68. data/lib/git/commands/config_option_syntax/remove_section.rb +79 -0
  69. data/lib/git/commands/config_option_syntax/rename_section.rb +83 -0
  70. data/lib/git/commands/config_option_syntax/replace_all.rb +109 -0
  71. data/lib/git/commands/config_option_syntax/set.rb +119 -0
  72. data/lib/git/commands/config_option_syntax/unset.rb +92 -0
  73. data/lib/git/commands/config_option_syntax/unset_all.rb +94 -0
  74. data/lib/git/commands/config_option_syntax.rb +56 -0
  75. data/lib/git/commands/describe.rb +156 -0
  76. data/lib/git/commands/diff.rb +657 -0
  77. data/lib/git/commands/diff_files.rb +519 -0
  78. data/lib/git/commands/diff_index.rb +499 -0
  79. data/lib/git/commands/fetch.rb +354 -0
  80. data/lib/git/commands/fsck.rb +138 -0
  81. data/lib/git/commands/gc.rb +134 -0
  82. data/lib/git/commands/grep.rb +339 -0
  83. data/lib/git/commands/init.rb +101 -0
  84. data/lib/git/commands/log.rb +634 -0
  85. data/lib/git/commands/ls_files.rb +195 -0
  86. data/lib/git/commands/ls_remote.rb +161 -0
  87. data/lib/git/commands/ls_tree.rb +135 -0
  88. data/lib/git/commands/maintenance/register.rb +77 -0
  89. data/lib/git/commands/maintenance/run.rb +109 -0
  90. data/lib/git/commands/maintenance/start.rb +71 -0
  91. data/lib/git/commands/maintenance/stop.rb +60 -0
  92. data/lib/git/commands/maintenance/unregister.rb +84 -0
  93. data/lib/git/commands/maintenance.rb +31 -0
  94. data/lib/git/commands/merge/abort.rb +44 -0
  95. data/lib/git/commands/merge/continue.rb +44 -0
  96. data/lib/git/commands/merge/quit.rb +46 -0
  97. data/lib/git/commands/merge/start.rb +250 -0
  98. data/lib/git/commands/merge.rb +28 -0
  99. data/lib/git/commands/merge_base.rb +91 -0
  100. data/lib/git/commands/mv.rb +82 -0
  101. data/lib/git/commands/name_rev.rb +119 -0
  102. data/lib/git/commands/pull.rb +382 -0
  103. data/lib/git/commands/push.rb +251 -0
  104. data/lib/git/commands/read_tree.rb +154 -0
  105. data/lib/git/commands/remote/add.rb +96 -0
  106. data/lib/git/commands/remote/get_url.rb +68 -0
  107. data/lib/git/commands/remote/list.rb +56 -0
  108. data/lib/git/commands/remote/prune.rb +63 -0
  109. data/lib/git/commands/remote/remove.rb +52 -0
  110. data/lib/git/commands/remote/rename.rb +76 -0
  111. data/lib/git/commands/remote/set_branches.rb +70 -0
  112. data/lib/git/commands/remote/set_head.rb +89 -0
  113. data/lib/git/commands/remote/set_url.rb +78 -0
  114. data/lib/git/commands/remote/set_url_add.rb +70 -0
  115. data/lib/git/commands/remote/set_url_delete.rb +71 -0
  116. data/lib/git/commands/remote/show.rb +77 -0
  117. data/lib/git/commands/remote/update.rb +79 -0
  118. data/lib/git/commands/remote.rb +42 -0
  119. data/lib/git/commands/repack.rb +281 -0
  120. data/lib/git/commands/reset.rb +154 -0
  121. data/lib/git/commands/rev_parse.rb +304 -0
  122. data/lib/git/commands/revert/abort.rb +45 -0
  123. data/lib/git/commands/revert/continue.rb +62 -0
  124. data/lib/git/commands/revert/quit.rb +47 -0
  125. data/lib/git/commands/revert/skip.rb +44 -0
  126. data/lib/git/commands/revert/start.rb +158 -0
  127. data/lib/git/commands/revert.rb +29 -0
  128. data/lib/git/commands/rm.rb +113 -0
  129. data/lib/git/commands/show.rb +632 -0
  130. data/lib/git/commands/show_ref/exclude_existing.rb +119 -0
  131. data/lib/git/commands/show_ref/exists.rb +80 -0
  132. data/lib/git/commands/show_ref/list.rb +149 -0
  133. data/lib/git/commands/show_ref/verify.rb +122 -0
  134. data/lib/git/commands/show_ref.rb +42 -0
  135. data/lib/git/commands/stash/apply.rb +81 -0
  136. data/lib/git/commands/stash/branch.rb +67 -0
  137. data/lib/git/commands/stash/clear.rb +43 -0
  138. data/lib/git/commands/stash/create.rb +60 -0
  139. data/lib/git/commands/stash/drop.rb +73 -0
  140. data/lib/git/commands/stash/list.rb +43 -0
  141. data/lib/git/commands/stash/pop.rb +87 -0
  142. data/lib/git/commands/stash/push.rb +112 -0
  143. data/lib/git/commands/stash/show.rb +158 -0
  144. data/lib/git/commands/stash/store.rb +72 -0
  145. data/lib/git/commands/stash.rb +38 -0
  146. data/lib/git/commands/status.rb +174 -0
  147. data/lib/git/commands/symbolic_ref/delete.rb +72 -0
  148. data/lib/git/commands/symbolic_ref/read.rb +99 -0
  149. data/lib/git/commands/symbolic_ref/update.rb +79 -0
  150. data/lib/git/commands/symbolic_ref.rb +38 -0
  151. data/lib/git/commands/tag/create.rb +142 -0
  152. data/lib/git/commands/tag/delete.rb +57 -0
  153. data/lib/git/commands/tag/list.rb +146 -0
  154. data/lib/git/commands/tag/verify.rb +71 -0
  155. data/lib/git/commands/tag.rb +26 -0
  156. data/lib/git/commands/update_ref/batch.rb +145 -0
  157. data/lib/git/commands/update_ref/delete.rb +90 -0
  158. data/lib/git/commands/update_ref/update.rb +103 -0
  159. data/lib/git/commands/update_ref.rb +42 -0
  160. data/lib/git/commands/version.rb +60 -0
  161. data/lib/git/commands/worktree/add.rb +139 -0
  162. data/lib/git/commands/worktree/list.rb +64 -0
  163. data/lib/git/commands/worktree/lock.rb +58 -0
  164. data/lib/git/commands/worktree/management_base.rb +51 -0
  165. data/lib/git/commands/worktree/move.rb +66 -0
  166. data/lib/git/commands/worktree/prune.rb +67 -0
  167. data/lib/git/commands/worktree/remove.rb +63 -0
  168. data/lib/git/commands/worktree/repair.rb +76 -0
  169. data/lib/git/commands/worktree/unlock.rb +47 -0
  170. data/lib/git/commands/worktree.rb +43 -0
  171. data/lib/git/commands/write_tree.rb +68 -0
  172. data/lib/git/commands.rb +88 -0
  173. data/lib/git/config.rb +72 -5
  174. data/lib/git/config_entry_info.rb +106 -0
  175. data/lib/git/configuring.rb +795 -0
  176. data/lib/git/detached_head_info.rb +57 -0
  177. data/lib/git/diff.rb +437 -86
  178. data/lib/git/diff_file_numstat_info.rb +31 -0
  179. data/lib/git/diff_file_patch_info.rb +136 -0
  180. data/lib/git/diff_file_raw_info.rb +129 -0
  181. data/lib/git/diff_info.rb +162 -0
  182. data/lib/git/diff_path_status.rb +107 -0
  183. data/lib/git/diff_result.rb +34 -0
  184. data/lib/git/diff_stats.rb +111 -0
  185. data/lib/git/dirstat_info.rb +102 -0
  186. data/lib/git/encoding_utils.rb +32 -1
  187. data/lib/git/errors.rb +285 -0
  188. data/lib/git/escaped_path.rb +57 -5
  189. data/lib/git/execution_context/global.rb +31 -0
  190. data/lib/git/execution_context/repository.rb +151 -0
  191. data/lib/git/execution_context.rb +559 -0
  192. data/lib/git/factories.rb +813 -0
  193. data/lib/git/file_ref.rb +77 -0
  194. data/lib/git/fsck_object.rb +56 -0
  195. data/lib/git/fsck_result.rb +132 -0
  196. data/lib/git/log.rb +306 -90
  197. data/lib/git/object.rb +563 -141
  198. data/lib/git/parsers/branch.rb +240 -0
  199. data/lib/git/parsers/cat_file.rb +111 -0
  200. data/lib/git/parsers/config_entry.rb +110 -0
  201. data/lib/git/parsers/diff.rb +792 -0
  202. data/lib/git/parsers/fsck.rb +144 -0
  203. data/lib/git/parsers/grep.rb +42 -0
  204. data/lib/git/parsers/ls_remote.rb +79 -0
  205. data/lib/git/parsers/ls_tree.rb +58 -0
  206. data/lib/git/parsers/remote.rb +162 -0
  207. data/lib/git/parsers/stash.rb +292 -0
  208. data/lib/git/parsers/status.rb +251 -0
  209. data/lib/git/parsers/tag.rb +341 -0
  210. data/lib/git/parsers/worktree.rb +185 -0
  211. data/lib/git/path_resolver.rb +206 -0
  212. data/lib/git/remote.rb +165 -12
  213. data/lib/git/remote_info.rb +203 -0
  214. data/lib/git/repository/branching.rb +964 -0
  215. data/lib/git/repository/committing.rb +246 -0
  216. data/lib/git/repository/context_helpers.rb +293 -0
  217. data/lib/git/repository/diffing.rb +785 -0
  218. data/lib/git/repository/inspecting.rb +252 -0
  219. data/lib/git/repository/logging.rb +410 -0
  220. data/lib/git/repository/maintenance.rb +65 -0
  221. data/lib/git/repository/merging.rb +451 -0
  222. data/lib/git/repository/object_operations.rb +1551 -0
  223. data/lib/git/repository/remote_operations.rb +984 -0
  224. data/lib/git/repository/shared_private.rb +120 -0
  225. data/lib/git/repository/staging.rb +587 -0
  226. data/lib/git/repository/stashing.rb +623 -0
  227. data/lib/git/repository/status_operations.rb +249 -0
  228. data/lib/git/repository/worktree_operations.rb +339 -0
  229. data/lib/git/repository.rb +484 -2
  230. data/lib/git/stash.rb +109 -12
  231. data/lib/git/stash_info.rb +102 -0
  232. data/lib/git/stashes.rb +169 -26
  233. data/lib/git/status.rb +308 -122
  234. data/lib/git/status_file_info.rb +258 -0
  235. data/lib/git/status_info.rb +189 -0
  236. data/lib/git/tag_delete_failure.rb +34 -0
  237. data/lib/git/tag_delete_result.rb +66 -0
  238. data/lib/git/tag_info.rb +99 -0
  239. data/lib/git/url.rb +15 -8
  240. data/lib/git/version.rb +113 -2
  241. data/lib/git/version_constraint.rb +85 -0
  242. data/lib/git/worktree.rb +150 -8
  243. data/lib/git/worktree_info.rb +128 -0
  244. data/lib/git/worktrees.rb +118 -13
  245. data/lib/git.rb +632 -234
  246. metadata +369 -54
  247. data/.github/stale.yml +0 -25
  248. data/.github/workflows/continuous_integration.yml +0 -49
  249. data/.gitignore +0 -10
  250. data/Dockerfile.changelog-rs +0 -12
  251. data/Gemfile +0 -5
  252. data/ISSUE_TEMPLATE.md +0 -15
  253. data/PULL_REQUEST_TEMPLATE.md +0 -9
  254. data/RELEASING.md +0 -70
  255. data/Rakefile +0 -60
  256. data/lib/git/base/factory.rb +0 -99
  257. data/lib/git/base.rb +0 -711
  258. data/lib/git/command_line_result.rb +0 -86
  259. data/lib/git/failed_error.rb +0 -53
  260. data/lib/git/git_execute_error.rb +0 -7
  261. data/lib/git/index.rb +0 -5
  262. data/lib/git/lib.rb +0 -1328
  263. data/lib/git/path.rb +0 -31
  264. data/lib/git/signaled_error.rb +0 -50
  265. data/lib/git/working_directory.rb +0 -4
@@ -0,0 +1,1551 @@
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 private
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 perl_regexp P 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
+ # @example Match a metacharacter against a non-ASCII character on Git for Windows
479
+ # repo.grep('^.PFEL', nil, perl_regexp: true)
480
+ #
481
+ # @param pattern [String] the pattern to search for
482
+ #
483
+ # @param path_limiter [String, Pathname, Array<String, Pathname>, nil]
484
+ # a path or array of paths to limit the search to, or `nil` for no limit
485
+ #
486
+ # @param opts [Hash] additional options for the grep command
487
+ #
488
+ # @option opts [String] :object ('HEAD') the tree-ish to search
489
+ #
490
+ # @option opts [Boolean, nil] :ignore_case (nil) ignore case
491
+ # distinctions in both the pattern and the file contents
492
+ #
493
+ # Alias: :i
494
+ #
495
+ # @option opts [Boolean, nil] :invert_match (nil) select non-matching
496
+ # lines
497
+ #
498
+ # Alias: :v
499
+ #
500
+ # @option opts [Boolean, nil] :extended_regexp (nil) use POSIX extended
501
+ # regular expressions for the pattern
502
+ #
503
+ # Alias: :E
504
+ #
505
+ # @option opts [Boolean, nil] :perl_regexp (nil) use Perl-compatible regular
506
+ # expressions (PCRE) for the pattern
507
+ #
508
+ # Requires a git built with PCRE support; git otherwise fails with
509
+ # "cannot use Perl-compatible regexes...".
510
+ #
511
+ # Alias: :P
512
+ #
513
+ # @return [Hash<String, Array<Array(Integer, String)>>] a hash mapping
514
+ # each `"treeish:filename"` key to an array of `[line_number, text]`
515
+ # pairs; returns an empty hash when no lines match
516
+ #
517
+ # @raise [ArgumentError] if unsupported options are provided
518
+ #
519
+ # @raise [Git::FailedError] if git exits with a non-zero status and
520
+ # stderr is non-empty (e.g. bad object reference)
521
+ #
522
+ # @note On Git for Windows, git's default regex engine matches *bytes* rather
523
+ # than characters, so a metacharacter such as `.` or a POSIX class such as
524
+ # `[[:alpha:]]` never matches a whole multi-byte character. The failure is
525
+ # silent: nothing raises, and the empty hash returned is indistinguishable
526
+ # from a pattern that genuinely does not occur in the tree. Pass
527
+ # `perl_regexp: true` to select PCRE, which does match characters. PCRE is a
528
+ # different dialect than git's default POSIX basic/extended regular
529
+ # expressions, so this is a deliberate choice by the caller rather than a
530
+ # transparent substitution, and it requires a git built with PCRE support.
531
+ #
532
+ # @see https://git-scm.com/docs/git-grep git-grep documentation
533
+ #
534
+ def grep(pattern, path_limiter = nil, opts = {})
535
+ SharedPrivate.assert_valid_opts!(GREP_ALLOWED_OPTS, **opts)
536
+ opts = opts.dup
537
+ object = opts.delete(:object) || 'HEAD'
538
+ opts[:pathspec] = Array(path_limiter).map(&:to_s) if path_limiter
539
+ result = Git::Commands::Grep.new(@execution_context).call(
540
+ object, pattern:, **opts, no_color: true, line_number: true, null: true
541
+ )
542
+ Private.parse_grep_result(result)
543
+ end
544
+
545
+ # Option keys accepted by {#archive}
546
+ ARCHIVE_ALLOWED_OPTS = %i[prefix remote path format add_gzip].freeze
547
+ private_constant :ARCHIVE_ALLOWED_OPTS
548
+
549
+ # Create an archive of the repository tree and write it to a file
550
+ #
551
+ # Writes the archive content to a file and returns the file path. The
552
+ # default format is `zip`. Pass `format: 'tar'` for an uncompressed tar
553
+ # archive, or `format: 'tgz'` for a gzip-compressed tar archive
554
+ # (equivalent to `format: 'tar'` with `add_gzip: true`).
555
+ #
556
+ # When no `file` path is given, a temporary file is created and its path
557
+ # is returned.
558
+ #
559
+ # **File replacement behavior when `file` is given:**
560
+ #
561
+ # The archive is first written to a staging file in the same directory as
562
+ # `file`. This means write permission is required on the parent directory
563
+ # of `file`, not just on `file` itself. Once the archive is fully written,
564
+ # the staging file atomically replaces `file` via rename.
565
+ #
566
+ # If `file` already exists, only its numeric permission bits are applied to
567
+ # the new archive; ownership, ACLs, and extended attributes are not
568
+ # transferred. If `file` does not exist, the archive receives the standard
569
+ # file creation mode (`0666 & ~umask`). On Windows, `File.chmod` has no
570
+ # effect, so the archive always receives the default creation mode
571
+ # regardless of whether `file` already exists.
572
+ #
573
+ # If `file` is a symlink that does not point to a directory, the symlink
574
+ # itself is replaced by the new archive file rather than writing through
575
+ # the link to its target. A symlink that points to a directory is treated
576
+ # as a directory and rejected with `ArgumentError`.
577
+ #
578
+ # @example Archive HEAD as a zip file
579
+ # repo.archive('HEAD', '/tmp/release.zip') #=> "/tmp/release.zip"
580
+ #
581
+ # @example Archive a tag as a tar file
582
+ # repo.archive('v1.0', '/tmp/release.tar', format: 'tar') #=> "/tmp/release.tar"
583
+ #
584
+ # @example Archive with a path prefix applied to every entry
585
+ # repo.archive('HEAD', '/tmp/out.tar', format: 'tar', prefix: 'myproject/')
586
+ # #=> "/tmp/out.tar"
587
+ #
588
+ # @example Archive a subdirectory only
589
+ # repo.archive('HEAD', '/tmp/src.tar', format: 'tar', path: 'src/')
590
+ # #=> "/tmp/src.tar"
591
+ #
592
+ # @param treeish [String] tree-ish to archive — commit SHA, tag, branch
593
+ # name, or tree SHA
594
+ #
595
+ # @param file [String, nil] (nil) destination file path; when `nil`, a
596
+ # unique temporary file is created and its path is returned
597
+ #
598
+ # @param opts [Hash] archive options
599
+ #
600
+ # @option opts [String] :format ('zip') archive format — `'tar'`, `'zip'`,
601
+ # or `'tgz'`; `'tgz'` is internally converted to `'tar'` with gzip
602
+ # post-processing
603
+ #
604
+ # @option opts [String] :prefix (nil) prefix prepended to every filename
605
+ # in the archive; typically ends with `/`
606
+ #
607
+ # @option opts [String] :path (nil) path within the tree to include in the
608
+ # archive; when given, only files under that path are archived
609
+ #
610
+ # @option opts [String] :remote (nil) retrieve the archive from a remote
611
+ # repository rather than the local one
612
+ #
613
+ # @option opts [Boolean, nil] :add_gzip (nil) apply gzip compression after
614
+ # writing the archive; set automatically when `format: 'tgz'` is given
615
+ #
616
+ # @return [String] path to the written archive file
617
+ #
618
+ # @raise [ArgumentError] if unsupported options are provided
619
+ #
620
+ # @raise [ArgumentError] if `file` is an existing directory
621
+ #
622
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
623
+ #
624
+ # @see https://git-scm.com/docs/git-archive git-archive documentation
625
+ #
626
+ def archive(treeish, file = nil, opts = {})
627
+ SharedPrivate.assert_valid_opts!(ARCHIVE_ALLOWED_OPTS, **opts)
628
+ raise ArgumentError, "#{file.inspect} is a directory" if file && File.directory?(file)
629
+
630
+ tmp = Private.write_archive_tmp(@execution_context, treeish, opts, dest_dir: Private.staging_dir_for(file))
631
+ return tmp unless file
632
+
633
+ Private.atomic_replace(tmp, file)
634
+ file
635
+ rescue StandardError
636
+ FileUtils.rm_f(tmp) if tmp
637
+ raise
638
+ end
639
+
640
+ # Returns a blob object for the given object reference
641
+ #
642
+ # The returned object is lazy: no git command is invoked until a property
643
+ # (e.g. {Git::Object::AbstractObject#sha}, {Git::Object::AbstractObject#contents})
644
+ # is accessed on the result.
645
+ #
646
+ # @example Get a blob from a treeish path
647
+ # repo.gblob('HEAD:README.md')
648
+ # #=> #<Git::Object::Blob ...>
649
+ #
650
+ # @param objectish [String] the object name (SHA, treeish path, ref, etc.)
651
+ #
652
+ # @return [Git::Object::Blob] the blob object
653
+ #
654
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
655
+ #
656
+ def gblob(objectish)
657
+ Git::Object.new(self, objectish, 'blob')
658
+ end
659
+
660
+ # Returns a commit object for the given object reference
661
+ #
662
+ # The returned object is lazy: no git command is invoked until a property
663
+ # (e.g. {Git::Object::AbstractObject#sha}, {Git::Object::Commit#message})
664
+ # is accessed on the result.
665
+ #
666
+ # @example Get a commit by symbolic ref
667
+ # repo.gcommit('HEAD')
668
+ # #=> #<Git::Object::Commit ...>
669
+ #
670
+ # @example Get a commit by abbreviated SHA
671
+ # repo.gcommit('abc1234')
672
+ # #=> #<Git::Object::Commit ...>
673
+ #
674
+ # @param objectish [String] the object name (SHA, branch, tag, refspec, etc.)
675
+ #
676
+ # @return [Git::Object::Commit] the commit object
677
+ #
678
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
679
+ #
680
+ def gcommit(objectish)
681
+ Git::Object.new(self, objectish, 'commit')
682
+ end
683
+
684
+ # Returns a tree object for the given object reference
685
+ #
686
+ # The returned object is lazy: no git command is invoked until a property
687
+ # (e.g. {Git::Object::AbstractObject#sha}, {Git::Object::Tree#children})
688
+ # is accessed on the result.
689
+ #
690
+ # @example Get the root tree for the current HEAD
691
+ # repo.gtree('HEAD^{tree}')
692
+ # #=> #<Git::Object::Tree ...>
693
+ #
694
+ # @param objectish [String] the object name (SHA, treeish specifier, etc.)
695
+ #
696
+ # @return [Git::Object::Tree] the tree object
697
+ #
698
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
699
+ #
700
+ def gtree(objectish)
701
+ Git::Object.new(self, objectish, 'tree')
702
+ end
703
+
704
+ # Returns a tag object for the given tag name
705
+ #
706
+ # Returns a {Git::Object::Tag} for `tag_name`. The returned object is
707
+ # either an annotated or a lightweight tag depending on the underlying
708
+ # ref type.
709
+ #
710
+ # @example Get a tag object
711
+ # repo.tag('v1.0')
712
+ # #=> #<Git::Object::Tag name="v1.0" ...>
713
+ #
714
+ # @param tag_name [String] the name of the tag
715
+ #
716
+ # @return [Git::Object::Tag] the tag object
717
+ #
718
+ # @raise [Git::UnexpectedResultError] if `tag_name` does not name an
719
+ # existing tag
720
+ #
721
+ # @raise [Git::FailedError] if the underlying `git show-ref` invocation
722
+ # exits with an unexpected status (i.e., outside the allowed 0..1 range)
723
+ #
724
+ # @deprecated Use `tag_list(name).first` instead
725
+ #
726
+ # {#tag_list} returns immutable {Git::TagInfo} value objects rather
727
+ # than {Git::Object::Tag}. `tag_list(name).first` is `nil` when the tag
728
+ # does not exist, where this method raises
729
+ # {Git::UnexpectedResultError}. Call the corresponding
730
+ # {Git::Repository} method (e.g. {#archive}, {#log}, {#diff},
731
+ # {#cat_file_contents}) with `info.oid || info.target_oid` for
732
+ # operations on a tag; that is the object this method's return value
733
+ # pins at construction, so a later move of the tag does not redirect
734
+ # it, whereas the tag name would. The
735
+ # {Git::Object::Tag} constructor is deprecated too; this method
736
+ # silences it so one call emits one warning.
737
+ #
738
+ # @see #tag_list
739
+ #
740
+ def tag(tag_name)
741
+ Git::Deprecation.warn(
742
+ 'Git::Repository#tag is deprecated and will be removed in v6.0.0. ' \
743
+ 'Use Git::Repository#tag_list(name).first instead.'
744
+ )
745
+ Git::Deprecation.silence { Git::Object::Tag.new(self, tag_name) }
746
+ end
747
+
748
+ # Returns the appropriate git object for the given object reference
749
+ #
750
+ # Runs `git cat-file -t` to determine the object type, then constructs
751
+ # and returns the corresponding `Git::Object::*` subclass instance.
752
+ #
753
+ # @example Get a commit object from HEAD
754
+ # repo.object('HEAD')
755
+ # #=> #<Git::Object::Commit ...>
756
+ #
757
+ # @example Get a blob from a treeish path
758
+ # repo.object('HEAD:README.md')
759
+ # #=> #<Git::Object::Blob ...>
760
+ #
761
+ # @param objectish [String] the object name (SHA, ref, treeish path, etc.)
762
+ #
763
+ # @return [Git::Object::Blob, Git::Object::Commit, Git::Object::Tree] the
764
+ # git object for the given reference
765
+ #
766
+ # @raise [ArgumentError] if `objectish` starts with a hyphen
767
+ #
768
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
769
+ #
770
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file documentation
771
+ #
772
+ def object(objectish)
773
+ Git::Object.new(self, objectish)
774
+ end
775
+
776
+ # Returns the tags in the repository as structured objects
777
+ #
778
+ # @example List all tags
779
+ # repo.tag_list
780
+ # # => [#<data Git::TagInfo name="v1.0.0", oid=nil, target_oid="abc123...", ...>,
781
+ # # #<data Git::TagInfo name="v2.0.0", oid="def456...", target_oid="789abc...", ...>]
782
+ #
783
+ # @example Look up a single tag by name
784
+ # repo.tag_list('v1.0.0').first
785
+ # # => #<data Git::TagInfo name="v1.0.0", ...>
786
+ #
787
+ # @example Look up a tag that does not exist
788
+ # repo.tag_list('nonexistent').first #=> nil
789
+ #
790
+ # @example Filter using glob patterns
791
+ # repo.tag_list('v1.*', 'v2.*')
792
+ #
793
+ # @example List only annotated tags
794
+ # repo.tag_list.select(&:annotated?)
795
+ #
796
+ # @param patterns [Array<String>] optional shell wildcard patterns passed
797
+ # directly to `git tag --list`; when empty (the default) all tags are
798
+ # returned
799
+ #
800
+ # @return [Array<Git::TagInfo>] parsed tag information for every tag
801
+ # matching the patterns, in the order `git tag --list` reports them
802
+ #
803
+ # Returns an empty array when the repository has no tags or no tag
804
+ # matches the given patterns.
805
+ #
806
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
807
+ #
808
+ # @see https://git-scm.com/docs/git-tag git-tag
809
+ #
810
+ def tag_list(*patterns)
811
+ result = Git::Commands::Tag::List.new(@execution_context).call(
812
+ *patterns, format: Git::Parsers::Tag::FORMAT_STRING
813
+ )
814
+ Git::Parsers::Tag.parse_list(result.stdout)
815
+ end
816
+
817
+ # Returns all tags in the repository as tag objects
818
+ #
819
+ # Runs `git tag --list` with a machine-readable format, parses the output,
820
+ # and returns a {Git::Object::Tag} for each tag name.
821
+ #
822
+ # @example List the names of all tags
823
+ # repo.tags.map(&:name) #=> ["v1.0.0", "v2.0.0"]
824
+ #
825
+ # @example No tags exist
826
+ # repo.tags #=> []
827
+ #
828
+ # @return [Array<Git::Object::Tag>] one tag object per tag in the
829
+ # repository; empty when there are none
830
+ #
831
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
832
+ #
833
+ # @deprecated Use {#tag_list} instead
834
+ #
835
+ # {#tag_list} returns `Array<Git::TagInfo>` (immutable value objects)
836
+ # rather than `Array<Git::Object::Tag>`. Look a tag up by name with
837
+ # `tag_list(name).first`, and call the corresponding {Git::Repository}
838
+ # method (e.g. {#archive}, {#log}, {#diff}, {#cat_file_contents}) with
839
+ # `info.oid || info.target_oid` for operations on a tag; that is the
840
+ # object each returned {Git::Object::Tag} pins at construction, so a
841
+ # later move of the tag does not redirect it, whereas the tag name
842
+ # would. The {Git::Object::Tag}
843
+ # constructor is deprecated too; this method silences it so one call
844
+ # emits one warning.
845
+ #
846
+ # @see #tag_list
847
+ #
848
+ def tags
849
+ Git::Deprecation.warn(
850
+ 'Git::Repository#tags is deprecated and will be removed in v6.0.0. ' \
851
+ 'Use Git::Repository#tag_list instead.'
852
+ )
853
+ Git::Deprecation.silence { tag_list.map { |info| Git::Object::Tag.new(self, info.name) } }
854
+ end
855
+
856
+ # Option keys accepted by {#tag_create} and {#tag_add}
857
+ TAG_CREATE_ALLOWED_OPTS = %i[
858
+ annotate a sign s no_sign local_user u force f message m file F
859
+ edit e no_edit trailer cleanup create_reflog
860
+ ].freeze
861
+ private_constant :TAG_CREATE_ALLOWED_OPTS
862
+
863
+ # Create a new tag and return its metadata
864
+ #
865
+ # @overload tag_create(name, options = {})
866
+ #
867
+ # @example Create a lightweight tag on HEAD
868
+ # repo.tag_create('v1.0.0')
869
+ # # => #<data Git::TagInfo name="v1.0.0", oid=nil, target_oid="abc123...", ...>
870
+ #
871
+ # @example Create an annotated tag on HEAD
872
+ # repo.tag_create('v1.0.0', annotate: true, message: 'Release 1.0.0')
873
+ # # => #<data Git::TagInfo name="v1.0.0", oid="def456...", message="Release 1.0.0", ...>
874
+ #
875
+ # @example Replace an existing tag on HEAD
876
+ # repo.tag_create('v1.0.0', force: true)
877
+ #
878
+ # @param name [String] the name of the tag to create
879
+ #
880
+ # @param options [Hash] options for creating the tag
881
+ #
882
+ # @option options [Boolean, nil] :annotate (nil) make an unsigned,
883
+ # annotated tag object; requires `:message` or `:file` (alias: `:a`)
884
+ #
885
+ # @option options [Boolean, nil] :a (nil) alias for `:annotate`
886
+ #
887
+ # @option options [Boolean, nil] :sign (nil) make a GPG-signed tag;
888
+ # requires `:message` or `:file` (alias: `:s`)
889
+ #
890
+ # @option options [Boolean, nil] :s (nil) alias for `:sign`
891
+ #
892
+ # @option options [Boolean, nil] :no_sign (nil) override `tag.gpgSign`
893
+ # config to disable signing
894
+ #
895
+ # @option options [String] :local_user (nil) make a signed tag using the
896
+ # given key (alias: `:u`)
897
+ #
898
+ # @option options [String] :u (nil) alias for `:local_user`
899
+ #
900
+ # @option options [Boolean, nil] :force (nil) replace an existing tag with
901
+ # the given name instead of failing (alias: `:f`)
902
+ #
903
+ # @option options [Boolean, nil] :f (nil) alias for `:force`
904
+ #
905
+ # @option options [String] :message (nil) use the given message as the tag
906
+ # message (alias: `:m`)
907
+ #
908
+ # @option options [String] :m (nil) alias for `:message`
909
+ #
910
+ # @option options [String] :file (nil) take the tag message from the given
911
+ # file; use `-` to read from standard input (alias: `:F`)
912
+ #
913
+ # @option options [String] :F (nil) alias for `:file`
914
+ #
915
+ # @option options [Boolean, nil] :edit (nil) open an editor to further edit
916
+ # the tag message (alias: `:e`)
917
+ #
918
+ # @option options [Boolean, nil] :e (nil) alias for `:edit`
919
+ #
920
+ # @option options [Boolean, nil] :no_edit (nil) suppress the editor
921
+ #
922
+ # @option options [Hash, Array<Array>] :trailer (nil) add trailers to the
923
+ # tag message
924
+ #
925
+ # @option options [String] :cleanup (nil) set how the tag message is
926
+ # cleaned up; one of `verbatim`, `whitespace`, or `strip`
927
+ #
928
+ # @option options [Boolean, nil] :create_reflog (nil) create a reflog for
929
+ # the tag
930
+ #
931
+ # @return [Git::TagInfo] the newly created tag
932
+ #
933
+ # @overload tag_create(name, target, options = {})
934
+ #
935
+ # @example Create a lightweight tag on a specific commit
936
+ # repo.tag_create('v1.0.0', 'abc123')
937
+ #
938
+ # @example Create an annotated tag on a specific commit
939
+ # repo.tag_create('v1.0.0', 'abc123', annotate: true, message: 'Release 1.0.0')
940
+ #
941
+ # @param name [String] the name of the tag to create
942
+ #
943
+ # @param target [String] the object to tag (commit SHA, branch name, etc.)
944
+ #
945
+ # @param options [Hash] options for creating the tag (same keys as the
946
+ # first overload)
947
+ #
948
+ # @return [Git::TagInfo] the newly created tag
949
+ #
950
+ # @raise [ArgumentError] if unsupported options are provided, including the
951
+ # `:d` and `:delete` keys that {#tag_add} accepts; use {#tag_delete} to
952
+ # delete a tag
953
+ #
954
+ # @raise [ArgumentError] if an annotated or signed tag is requested without
955
+ # a message
956
+ #
957
+ # @raise [ArgumentError] if more than one positional argument follows the
958
+ # name (before any options hash); {#tag_add} silently ignored the extra
959
+ # arguments and tagged the first
960
+ #
961
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
962
+ #
963
+ # @see https://git-scm.com/docs/git-tag git-tag
964
+ #
965
+ def tag_create(name, *args)
966
+ target, options = Private.tag_target_and_options(args, strict: true)
967
+ SharedPrivate.assert_valid_opts!(TAG_CREATE_ALLOWED_OPTS, **options)
968
+ Private.create_tag(@execution_context, name, target, options)
969
+ tag_list(name).first
970
+ end
971
+
972
+ # Create a new tag
973
+ #
974
+ # @overload tag_add(name, options = {})
975
+ #
976
+ # @example Create a lightweight tag on HEAD
977
+ # repo.tag_add('v1.0.0')
978
+ #
979
+ # @example Create an annotated tag on HEAD
980
+ # repo.tag_add('v1.0.0', annotate: true, message: 'Release 1.0.0')
981
+ #
982
+ # @example Replace an existing tag on HEAD
983
+ # repo.tag_add('v1.0.0', force: true)
984
+ #
985
+ # @param name [String] the name of the tag to create
986
+ #
987
+ # @param options [Hash] options for creating the tag (same keys as
988
+ # {#tag_create})
989
+ #
990
+ # @return [Git::Object::Tag] the newly created tag
991
+ #
992
+ # @overload tag_add(name, target, options = {})
993
+ #
994
+ # @example Create a lightweight tag on a specific commit
995
+ # repo.tag_add('v1.0.0', 'abc123')
996
+ #
997
+ # @example Create an annotated tag on a specific commit
998
+ # repo.tag_add('v1.0.0', 'abc123', annotate: true, message: 'Release 1.0.0')
999
+ #
1000
+ # @param name [String] the name of the tag to create
1001
+ #
1002
+ # @param target [String] the object to tag (commit SHA, branch name, etc.)
1003
+ #
1004
+ # @param options [Hash] options for creating the tag (same keys as
1005
+ # {#tag_create})
1006
+ #
1007
+ # @return [Git::Object::Tag] the newly created tag
1008
+ #
1009
+ # @overload tag_add(name, delete_options)
1010
+ #
1011
+ # @deprecated Use {#tag_delete} instead.
1012
+ #
1013
+ # @example Delete a tag (deprecated)
1014
+ # repo.tag_add('v1.0.0', d: true)
1015
+ #
1016
+ # @param name [String] the name of the tag to delete
1017
+ #
1018
+ # @param delete_options [Hash{Symbol => Boolean}] deletion options;
1019
+ # only `:d` or `:delete` (set to `true`) is accepted — no other keys
1020
+ # and no `target` argument may be combined with this form
1021
+ #
1022
+ # @return [String] git's stdout from the delete
1023
+ #
1024
+ # @raise [ArgumentError] if a target is also provided
1025
+ #
1026
+ # @raise [ArgumentError] if options other than `:d`/`:delete` are also
1027
+ # provided
1028
+ #
1029
+ # @raise [ArgumentError] if unsupported options are provided
1030
+ #
1031
+ # @raise [ArgumentError] if an annotated or signed tag is requested without
1032
+ # a message
1033
+ #
1034
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1035
+ #
1036
+ # @deprecated Use {#tag_create} instead
1037
+ #
1038
+ # {#tag_create} accepts the same `name`, `target`, and options and
1039
+ # returns a {Git::TagInfo} (an immutable value object) rather than a
1040
+ # {Git::Object::Tag}. It does not accept the `:d`/`:delete` form; use
1041
+ # {#tag_delete} for that. The {Git::Object::Tag} constructor is
1042
+ # deprecated too; this method silences it so one call emits one
1043
+ # warning, except that the `:d`/`:delete` form emits a second warning
1044
+ # of its own.
1045
+ #
1046
+ # @see #tag_create
1047
+ #
1048
+ def tag_add(name, *args)
1049
+ Git::Deprecation.warn(
1050
+ 'Git::Repository#tag_add is deprecated and will be removed in v6.0.0. ' \
1051
+ 'Use Git::Repository#tag_create instead.'
1052
+ )
1053
+ target, options = Private.tag_target_and_options(args)
1054
+
1055
+ return Private.tag_add_delete_deprecated(self, name, target, options) if options[:d] || options[:delete]
1056
+
1057
+ options = options.except(:d, :delete)
1058
+ SharedPrivate.assert_valid_opts!(TAG_CREATE_ALLOWED_OPTS, **options)
1059
+ Private.create_tag(@execution_context, name, target, options)
1060
+ Git::Deprecation.silence { Git::Object::Tag.new(self, name) }
1061
+ end
1062
+
1063
+ # @overload add_tag(name, options = {})
1064
+ #
1065
+ # @param name [String] the name of the tag to create
1066
+ #
1067
+ # @param options [Hash] options for creating the tag
1068
+ #
1069
+ # @return [Git::Object::Tag] the newly created tag
1070
+ #
1071
+ # @overload add_tag(name, target, options = {})
1072
+ #
1073
+ # @param name [String] the name of the tag to create
1074
+ #
1075
+ # @param target [String] the object to tag (commit SHA, branch name, etc.)
1076
+ #
1077
+ # @param options [Hash] options for creating the tag
1078
+ #
1079
+ # @return [Git::Object::Tag] the newly created tag
1080
+ #
1081
+ # @raise [ArgumentError] if unsupported options are provided
1082
+ #
1083
+ # @raise [ArgumentError] if an annotated or signed tag is requested without
1084
+ # a message
1085
+ #
1086
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1087
+ #
1088
+ # @deprecated Use {#tag_create} instead
1089
+ #
1090
+ # The warning names {#tag_add}, the replacement this method shipped
1091
+ # with, and {#tag_add} is deprecated as well, so a creation call emits
1092
+ # two warnings: one for this method and one for {#tag_add}. The delete
1093
+ # form `add_tag(name, d: true)` emits a third, for the deprecated `:d`
1094
+ # and `:delete` options on {#tag_add}; use {#tag_delete} for that. Go
1095
+ # straight to {#tag_create} for creation.
1096
+ #
1097
+ def add_tag(name, *)
1098
+ Git::Deprecation.warn(
1099
+ 'Git::Repository#add_tag is deprecated and will be removed in v6.0.0. ' \
1100
+ 'Use Git::Repository#tag_add instead.'
1101
+ )
1102
+ tag_add(name, *)
1103
+ end
1104
+
1105
+ # Delete a tag
1106
+ #
1107
+ # @example Delete a tag
1108
+ # repo.tag_delete('v1.0.0')
1109
+ #
1110
+ # @param name [String] the name of the tag to delete
1111
+ #
1112
+ # @return [String] git's stdout from the delete
1113
+ #
1114
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1115
+ #
1116
+ def tag_delete(name)
1117
+ result = Git::Commands::Tag::Delete.new(@execution_context).call(name)
1118
+ raise Git::FailedError, result if result.status.exitstatus.positive?
1119
+
1120
+ result.stdout
1121
+ end
1122
+
1123
+ # @param name [String] the name of the tag to delete
1124
+ #
1125
+ # @return [String] git's stdout from the delete
1126
+ #
1127
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1128
+ #
1129
+ # @deprecated Use {#tag_delete} instead
1130
+ #
1131
+ def delete_tag(name)
1132
+ Git::Deprecation.warn(
1133
+ 'Git::Repository#delete_tag is deprecated and will be removed in v6.0.0. ' \
1134
+ 'Use Git::Repository#tag_delete instead.'
1135
+ )
1136
+ tag_delete(name)
1137
+ end
1138
+
1139
+ # Private helpers
1140
+ #
1141
+ # @api private
1142
+ #
1143
+ module Private
1144
+ module_function
1145
+
1146
+ # Splits the variadic `*args` of {ObjectOperations#tag_create} and
1147
+ # {ObjectOperations#tag_add} into the target and the options hash
1148
+ #
1149
+ # Both methods accept `(name, opts = {})` and `(name, target, opts = {})`,
1150
+ # so a trailing `Hash` is the options and anything before it is the
1151
+ # target.
1152
+ #
1153
+ # @param args [Array] the arguments after the tag name
1154
+ #
1155
+ # @param strict [Boolean] when `true`, raise instead of silently ignoring
1156
+ # a second positional argument before the options; `tag_create` is
1157
+ # strict, while the deprecated `tag_add` keeps its lenient behavior
1158
+ #
1159
+ # @return [Array((String, nil), Hash)] the two-element tuple
1160
+ # `[target, options]`; `target` is `nil` when only options were given
1161
+ #
1162
+ # @raise [ArgumentError] if `strict` is `true` and more than one
1163
+ # positional argument precedes the options hash
1164
+ #
1165
+ # @api private
1166
+ #
1167
+ def tag_target_and_options(args, strict: false)
1168
+ args = args.dup
1169
+ options = args.last.is_a?(Hash) ? args.pop : {}
1170
+ if strict && args.size > 1
1171
+ raise ArgumentError,
1172
+ "Expected at most one target before the options, got #{args.size}: #{args.inspect}"
1173
+ end
1174
+
1175
+ [args.first, options]
1176
+ end
1177
+
1178
+ # Validates the tag-creation options and runs `git tag`
1179
+ #
1180
+ # @param execution_context [Git::ExecutionContext::Repository] the
1181
+ # execution context for git commands
1182
+ #
1183
+ # @param name [String] the name of the tag to create
1184
+ #
1185
+ # @param target [String, nil] the object to tag, or `nil` for HEAD
1186
+ #
1187
+ # @param options [Hash] the tag-creation options, already checked
1188
+ # against the allowed keys (see {ObjectOperations#tag_create} for the
1189
+ # full list)
1190
+ #
1191
+ # @option options [Boolean, nil] :annotate (nil) make an annotated tag;
1192
+ # requires a message (alias: `:a`)
1193
+ #
1194
+ # @option options [Boolean, nil] :sign (nil) make a signed tag; requires
1195
+ # a message (alias: `:s`)
1196
+ #
1197
+ # @option options [String] :local_user (nil) sign with the given key;
1198
+ # requires a message (alias: `:u`)
1199
+ #
1200
+ # @option options [String] :message (nil) the tag message (alias: `:m`)
1201
+ #
1202
+ # @option options [String] :file (nil) a file to read the tag message
1203
+ # from (alias: `:F`)
1204
+ #
1205
+ # @option options [Boolean, nil] :force (nil) replace an existing tag
1206
+ # (alias: `:f`)
1207
+ #
1208
+ # @return [void]
1209
+ #
1210
+ # @raise [ArgumentError] when an annotated or signed tag is requested
1211
+ # without a message
1212
+ #
1213
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
1214
+ #
1215
+ # @api private
1216
+ #
1217
+ def create_tag(execution_context, name, target, options)
1218
+ validate_tag_options!(options)
1219
+ Git::Commands::Tag::Create.new(execution_context).call(name, target, **options)
1220
+ end
1221
+
1222
+ # Validate that a message is present when an annotated or signed tag is
1223
+ # requested
1224
+ #
1225
+ # @param opts [Hash] the tag-creation options
1226
+ #
1227
+ # @option opts [Boolean, nil] :annotate request an annotated tag
1228
+ #
1229
+ # @option opts [Boolean, nil] :a alias for `:annotate`
1230
+ #
1231
+ # @option opts [Boolean, nil] :sign request a signed tag
1232
+ #
1233
+ # @option opts [Boolean, nil] :s alias for `:sign`
1234
+ #
1235
+ # @option opts [String] :local_user key id used when signing
1236
+ #
1237
+ # @option opts [String] :u alias for `:local_user`
1238
+ #
1239
+ # @option opts [String] :message tag message text
1240
+ #
1241
+ # @option opts [String] :m alias for `:message`
1242
+ #
1243
+ # @option opts [String] :file path to a tag message file
1244
+ #
1245
+ # @option opts [String] :F alias for `:file`
1246
+ #
1247
+ # @return [void]
1248
+ #
1249
+ # @raise [ArgumentError] when an annotated or signed tag is requested
1250
+ # without a `:message`/`:m`/`:file`/`:F` value
1251
+ #
1252
+ def validate_tag_options!(opts)
1253
+ needs_message = %i[a annotate s sign u local_user].any? { |k| opts[k] }
1254
+ has_message = opts[:m] || opts[:message] || opts[:F] || opts[:file]
1255
+
1256
+ return unless needs_message && !has_message
1257
+
1258
+ raise ArgumentError, 'Cannot create an annotated or signed tag without a message.'
1259
+ end
1260
+
1261
+ # Handle the deprecated :d/:delete option on tag_add
1262
+ #
1263
+ # Issues a deprecation warning and delegates to tag_delete. Raises
1264
+ # ArgumentError if a target or incompatible options are also supplied.
1265
+ #
1266
+ # @param facade [ObjectOperations] the calling facade instance
1267
+ #
1268
+ # @param name [String] tag name
1269
+ #
1270
+ # @param target [String, nil] target argument (must be nil)
1271
+ #
1272
+ # @param opts [Hash] options hash (must contain only :d/:delete)
1273
+ #
1274
+ # @option opts [Boolean] :d (true) request deletion in the deprecated
1275
+ # `tag_add` form
1276
+ #
1277
+ # @option opts [Boolean] :delete (true) alias for `:d`
1278
+ #
1279
+ # @return [String] stdout from tag_delete
1280
+ #
1281
+ # @api private
1282
+ #
1283
+ def tag_add_delete_deprecated(facade, name, target, opts)
1284
+ Git::Deprecation.warn(
1285
+ 'Passing :d or :delete to tag_add is deprecated and will be removed in v6.0.0. ' \
1286
+ 'Use tag_delete instead.'
1287
+ )
1288
+ raise ArgumentError, 'Cannot pass a target when using the :d/:delete option.' if target
1289
+
1290
+ extra = opts.keys - %i[d delete]
1291
+ raise ArgumentError, "Cannot combine :d/:delete with other options: #{extra.join(', ')}" unless extra.empty?
1292
+
1293
+ facade.tag_delete(name)
1294
+ end
1295
+
1296
+ # Returns the direct SHA for a tag reference
1297
+ #
1298
+ # Returns the hash from `refs/tags/<name>` only. Returns an empty string
1299
+ # when the ref does not exist.
1300
+ #
1301
+ # @param execution_context [Git::ExecutionContext] for running
1302
+ # `git show-ref`
1303
+ #
1304
+ # @param tag_name [String] tag name without the `refs/tags/` prefix
1305
+ #
1306
+ # @return [String] direct ref SHA or an empty string when missing
1307
+ #
1308
+ # @api private
1309
+ #
1310
+ def show_ref_tag_sha(execution_context, tag_name)
1311
+ ref = "refs/tags/#{tag_name}"
1312
+ result = Git::Commands::ShowRef::List.new(execution_context).call(ref)
1313
+ return '' if result.status.exitstatus == 1
1314
+
1315
+ line = result.stdout.lines.find { |l| l.split[1] == ref }
1316
+ line ? line.split[0] : ''
1317
+ end
1318
+
1319
+ # Parses the result of a git grep command
1320
+ #
1321
+ # @param result [Git::CommandLine::Result] the result of a git grep command
1322
+ #
1323
+ # @return [Hash<String, Array<Array(Integer, String)>>] hash mapping "treeish:filename"
1324
+ # keys to arrays of [line_number, text] pairs
1325
+ #
1326
+ def parse_grep_result(result)
1327
+ exitstatus = result.status.exitstatus
1328
+ return {} if exitstatus == 1 && result.stderr.empty?
1329
+ raise Git::FailedError, result if exitstatus == 1
1330
+
1331
+ Git::Parsers::Grep.parse(result.stdout)
1332
+ end
1333
+
1334
+ # Resolve the staging directory for a git archive temp file
1335
+ #
1336
+ # Always returns `Dir.tmpdir` when `file` is nil, or the parent
1337
+ # directory of `file` otherwise. Staging the temp file in the same
1338
+ # directory as the destination keeps both paths on the same filesystem
1339
+ # so that {#atomic_replace} can use an atomic rename that
1340
+ # requires no extra disk space.
1341
+ #
1342
+ # @param file [String, nil] the explicit destination path, or nil
1343
+ #
1344
+ # @return [String] directory path to pass to `Tempfile.create`
1345
+ #
1346
+ # @api private
1347
+ #
1348
+ def staging_dir_for(file)
1349
+ return Dir.tmpdir unless file
1350
+
1351
+ File.dirname(File.expand_path(file))
1352
+ end
1353
+
1354
+ # Write a git archive to a fresh temporary file and return its path
1355
+ #
1356
+ # Always writes to a new temporary file so that on error the caller's
1357
+ # destination file is never truncated. Format and gzip post-processing
1358
+ # are determined from `opts` via {#parse_archive_format_options}.
1359
+ #
1360
+ # @param execution_context [Git::ExecutionContext] for the git command
1361
+ #
1362
+ # @param treeish [String] tree-ish passed to `git archive`
1363
+ #
1364
+ # @param dest_dir [String] directory for the staging temp file; use
1365
+ # {#staging_dir_for} to select the optimal directory for the destination
1366
+ #
1367
+ # @param opts [Hash] caller-supplied options (read-only)
1368
+ #
1369
+ # @option opts [String] :format ('zip') archive format (`'tar'`, `'zip'`,
1370
+ # or `'tgz'`)
1371
+ #
1372
+ # @option opts [Boolean, nil] :add_gzip (nil) apply gzip post-processing
1373
+ # to the generated archive
1374
+ #
1375
+ # @option opts [String] :prefix (nil) prefix for entries in the archive
1376
+ #
1377
+ # @option opts [String] :path (nil) path within `treeish` to archive
1378
+ #
1379
+ # @option opts [String] :remote (nil) remote repository from which to
1380
+ # retrieve the archive
1381
+ #
1382
+ # @return [String] path to the populated temporary file
1383
+ #
1384
+ # @api private
1385
+ #
1386
+ def write_archive_tmp(execution_context, treeish, opts, dest_dir: Dir.tmpdir)
1387
+ format, gzip = parse_archive_format_options(opts)
1388
+ tmp_file = create_archive_tempfile(execution_context, treeish, opts, format, dest_dir)
1389
+ apply_gzip(tmp_file.path) if gzip
1390
+ tmp_file.path
1391
+ rescue StandardError
1392
+ tmp_file.close unless tmp_file.nil? || tmp_file.closed?
1393
+ FileUtils.rm_f(tmp_file.path) if tmp_file
1394
+ raise
1395
+ end
1396
+
1397
+ # Create a staging file, write the archive into it, close it, and return it
1398
+ #
1399
+ # Uses `Tempfile.create` (not `Tempfile.new`) so that no GC finalizer is
1400
+ # registered on the returned object — the file path remains valid after this
1401
+ # method returns and after the caller stores only the path string.
1402
+ #
1403
+ # @param execution_context [Git::ExecutionContext] for the git command
1404
+ #
1405
+ # @param treeish [String] tree-ish passed to `git archive`
1406
+ #
1407
+ # @param format [String] archive format string (e.g. `'zip'` or `'tar'`)
1408
+ #
1409
+ # @param dest_dir [String] directory in which to create the temp file
1410
+ #
1411
+ # @param opts [Hash] caller-supplied options (read-only; used for :prefix,
1412
+ # :remote, and :path)
1413
+ #
1414
+ # @option opts [String] :prefix (nil) prefix for entries in the archive
1415
+ #
1416
+ # @option opts [String] :path (nil) path within `treeish` to archive
1417
+ #
1418
+ # @option opts [String] :remote (nil) remote repository from which to
1419
+ # retrieve the archive
1420
+ #
1421
+ # @return [File] the closed file containing the archive
1422
+ #
1423
+ # @api private
1424
+ #
1425
+ def create_archive_tempfile(execution_context, treeish, opts, format, dest_dir)
1426
+ tmp_file = Tempfile.create('archive', dest_dir).tap(&:binmode)
1427
+ run_archive_command(execution_context, treeish, opts, format, tmp_file)
1428
+ tmp_file.close
1429
+ tmp_file
1430
+ rescue StandardError
1431
+ tmp_file&.close
1432
+ FileUtils.rm_f(tmp_file.path) if tmp_file
1433
+ raise
1434
+ end
1435
+
1436
+ # Invoke `git archive` and stream output into `tmp_file`
1437
+ #
1438
+ # @param execution_context [Git::ExecutionContext] for the git command
1439
+ #
1440
+ # @param treeish [String] tree-ish passed to `git archive`
1441
+ #
1442
+ # @param format [String] archive format to pass to `git archive --format`
1443
+ #
1444
+ # @param tmp_file [File] open, binary-mode IO to write archive data to
1445
+ #
1446
+ # @param opts [Hash] caller-supplied options (read-only; used for :prefix,
1447
+ # :remote, and :path)
1448
+ #
1449
+ # @option opts [String] :prefix (nil) prefix for entries in the archive
1450
+ #
1451
+ # @option opts [String] :path (nil) path within `treeish` to archive
1452
+ #
1453
+ # @option opts [String] :remote (nil) remote repository from which to
1454
+ # retrieve the archive
1455
+ #
1456
+ # @return [Git::CommandLine::Result] the result of the git command
1457
+ #
1458
+ # @api private
1459
+ #
1460
+ def run_archive_command(execution_context, treeish, opts, format, tmp_file)
1461
+ command_opts = opts.slice(:prefix, :remote).merge(format: format)
1462
+ path_args = opts[:path] ? [opts[:path]] : []
1463
+ Git::Commands::Archive.new(execution_context).call(treeish, *path_args, **command_opts, out: tmp_file)
1464
+ end
1465
+
1466
+ # Atomically rename the staging file `src` to `dest`, replacing any
1467
+ # existing file at `dest`. Both paths must be on the same filesystem
1468
+ # (guaranteed when `src` is created by {#staging_dir_for}).
1469
+ #
1470
+ # Before the rename, the staging file's permissions are set to the
1471
+ # existing file's numeric mode (if `dest` already existed) or to
1472
+ # `0666 & ~umask` (standard creation mode) for new files. The chmod
1473
+ # is applied to `src` before the rename so that, if chmod fails, `src`
1474
+ # is still present and can be cleaned up by the rescue. Only the
1475
+ # numeric permission bits are carried over; ownership, ACLs, and
1476
+ # extended attributes from an existing `dest` are not preserved.
1477
+ #
1478
+ # If `dest` is a symlink, the symlink itself is replaced by the renamed
1479
+ # staging file rather than writing through the link to its target.
1480
+ #
1481
+ # @param src [String] staging file path to rename; removed on success
1482
+ #
1483
+ # @param dest [String] destination file path
1484
+ #
1485
+ # @return [void]
1486
+ #
1487
+ # @api private
1488
+ #
1489
+ def atomic_replace(src, dest)
1490
+ mode = File.exist?(dest) ? (File.stat(dest).mode & 0o777) : (0o666 & ~File.umask)
1491
+ File.chmod(mode, src)
1492
+ File.rename(src, dest)
1493
+ rescue StandardError
1494
+ FileUtils.rm_f(src)
1495
+ raise
1496
+ end
1497
+
1498
+ # Determine the archive format and whether to apply gzip post-processing
1499
+ #
1500
+ # The `tgz` pseudo-format is not understood by `git archive` directly;
1501
+ # it is converted to `tar` and the gzip flag is set so that {#archive}
1502
+ # applies gzip compression after the archive is written.
1503
+ #
1504
+ # @param opts [Hash] caller-supplied options hash (read-only)
1505
+ #
1506
+ # @option opts [String] :format ('zip') archive format (`'tar'`, `'zip'`,
1507
+ # or `'tgz'`)
1508
+ #
1509
+ # @option opts [Boolean, nil] :add_gzip (nil) apply gzip post-processing
1510
+ # after archive generation
1511
+ #
1512
+ # @return [Array(String, Boolean)] a two-element array `[format, gzip]`
1513
+ #
1514
+ # `format` is the string to pass to `git archive --format`; `gzip` is
1515
+ # `true` when the caller should apply gzip post-processing after writing
1516
+ # the archive.
1517
+ #
1518
+ # @api private
1519
+ #
1520
+ def parse_archive_format_options(opts)
1521
+ format = opts[:format] || 'zip'
1522
+ gzip = opts[:add_gzip] == true || format == 'tgz'
1523
+ [format == 'tgz' ? 'tar' : format, gzip]
1524
+ end
1525
+
1526
+ # Apply gzip compression to the given file in place
1527
+ #
1528
+ # Streams from the source file through a {Zlib::GzipWriter} into a sibling
1529
+ # temporary file, then replaces the original. Peak memory is proportional
1530
+ # to the stream buffer rather than the full archive size.
1531
+ #
1532
+ # @param file [String] path to the file to compress in place
1533
+ #
1534
+ # @return [void]
1535
+ #
1536
+ # @api private
1537
+ #
1538
+ def apply_gzip(file)
1539
+ gz_tmp = Tempfile.create('archive_gz', File.dirname(file)).tap(&:close).path
1540
+ Zlib::GzipWriter.open(gz_tmp) { |gz| File.open(file, 'rb') { |f| IO.copy_stream(f, gz) } }
1541
+ FileUtils.rm_f(file)
1542
+ File.rename(gz_tmp, file)
1543
+ rescue StandardError
1544
+ FileUtils.rm_f(gz_tmp) if gz_tmp
1545
+ raise
1546
+ end
1547
+ end
1548
+ private_constant :Private
1549
+ end
1550
+ end
1551
+ end