git 4.3.2 → 5.0.0.beta.1

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