git 4.4.1 → 5.0.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/.github/copilot-instructions.md +67 -2705
  3. data/.github/pull_request_template.md +3 -1
  4. data/.github/skills/breaking-change-analysis/SKILL.md +102 -0
  5. data/.github/skills/ci-cd-troubleshooting/SKILL.md +264 -0
  6. data/.github/skills/command-implementation/REFERENCE.md +993 -0
  7. data/.github/skills/command-implementation/SKILL.md +229 -0
  8. data/.github/skills/command-test-conventions/SKILL.md +660 -0
  9. data/.github/skills/command-yard-documentation/SKILL.md +426 -0
  10. data/.github/skills/dependency-management/SKILL.md +72 -0
  11. data/.github/skills/development-workflow/SKILL.md +506 -0
  12. data/.github/skills/extract-command-from-lib/SKILL.md +487 -0
  13. data/.github/skills/extract-facade-from-base-lib/SKILL.md +586 -0
  14. data/.github/skills/facade-implementation/REFERENCE.md +840 -0
  15. data/.github/skills/facade-implementation/SKILL.md +260 -0
  16. data/.github/skills/facade-test-conventions/SKILL.md +380 -0
  17. data/.github/skills/facade-yard-documentation/SKILL.md +429 -0
  18. data/.github/skills/make-skill-template/SKILL.md +176 -0
  19. data/.github/skills/pr-readiness-review/SKILL.md +185 -0
  20. data/.github/skills/project-context/SKILL.md +313 -0
  21. data/.github/skills/pull-request-review/SKILL.md +168 -0
  22. data/.github/skills/refactor-command-to-commandlineresult/SKILL.md +131 -0
  23. data/.github/skills/release-management/SKILL.md +125 -0
  24. data/.github/skills/review-arguments-dsl/CHECKLIST.md +788 -0
  25. data/.github/skills/review-arguments-dsl/SKILL.md +214 -0
  26. data/.github/skills/review-backward-compatibility/SKILL.md +275 -0
  27. data/.github/skills/review-cross-command-consistency/SKILL.md +139 -0
  28. data/.github/skills/reviewing-skills/SKILL.md +189 -0
  29. data/.github/skills/rspec-unit-testing-standards/SKILL.md +639 -0
  30. data/.github/skills/tdd-refactor-step/SKILL.md +236 -0
  31. data/.github/skills/test-debugging/SKILL.md +160 -0
  32. data/.github/skills/yard-documentation/SKILL.md +793 -0
  33. data/.github/workflows/continuous_integration.yml +3 -67
  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 -66
  45. data/CONTRIBUTING.md +694 -53
  46. data/README.md +17 -17
  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 +488 -412
  52. data/lib/git/branch.rb +117 -59
  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 -1085
  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 +226 -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 +117 -56
  268. data/redesign/1_architecture_existing.md +54 -18
  269. data/redesign/2_architecture_redesign.md +365 -46
  270. data/redesign/3_architecture_implementation.md +1451 -54
  271. data/tasks/gem_tasks.rake +4 -0
  272. data/tasks/npm_tasks.rake +7 -0
  273. data/tasks/rspec.rake +48 -0
  274. data/tasks/test.rake +13 -1
  275. data/tasks/yard.rake +34 -7
  276. metadata +349 -20
  277. data/lib/git/index.rb +0 -6
  278. data/lib/git/path.rb +0 -38
  279. data/lib/git/working_directory.rb +0 -6
  280. /data/{release-please-config.json → .release-please-config.json} +0 -0
data/lib/git/branches.rb CHANGED
@@ -1,68 +1,174 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'git/base'
4
+
3
5
  module Git
4
- # object that holds all the available branches
6
+ # Collection of all Git branches in a repository
7
+ #
8
+ # Wraps both local and remote-tracking branches and provides filtering,
9
+ # enumeration, and name-based lookup.
10
+ #
11
+ # @example Enumerate all branches
12
+ # branches = repo.branches
13
+ # branches.each { |b| puts b.name }
14
+ #
15
+ # @api public
16
+ #
5
17
  class Branches
6
18
  include Enumerable
7
19
 
20
+ # Creates a new Branches collection populated from the given repository
21
+ #
22
+ # @param base [Git::Base, Git::Repository] the repository to enumerate
23
+ # branches from
24
+ #
25
+ # @return [void]
26
+ #
27
+ # @raise [Git::FailedError] if git exits with a non-zero exit status
28
+ #
8
29
  def initialize(base)
9
30
  @branches = {}
31
+ @lookup = {}
10
32
 
11
33
  @base = base
12
34
 
13
- @base.lib.branches_all.each do |b|
14
- @branches[b[0]] = Git::Branch.new(@base, b[0])
35
+ branch_repository.branches_all.each do |branch_info|
36
+ branch = Git::Branch.new(base, branch_info)
37
+
38
+ @branches[branch_info.refname] = branch
39
+ index_branch_lookup(branch, refname: branch_info.refname)
15
40
  end
16
41
  end
17
42
 
43
+ # Returns all local (non-remote-tracking) branches
44
+ #
45
+ # @example List local branch names
46
+ # repo.branches.local.map(&:name)
47
+ #
48
+ # @return [Array<Git::Branch>] the local branches
49
+ #
18
50
  def local
19
51
  reject(&:remote)
20
52
  end
21
53
 
54
+ # Returns all remote-tracking branches
55
+ #
56
+ # @example List remote branch names
57
+ # repo.branches.remote.map(&:name)
58
+ #
59
+ # @return [Array<Git::Branch>] the remote-tracking branches
60
+ #
22
61
  def remote
23
62
  self.select(&:remote)
24
63
  end
25
64
 
26
- # array like methods
27
-
65
+ # Returns the number of branches in the collection
66
+ #
67
+ # @example Count all branches
68
+ # repo.branches.size # => 3
69
+ #
70
+ # @return [Integer] the total number of branches
71
+ #
28
72
  def size
29
73
  @branches.size
30
74
  end
31
75
 
76
+ # Iterates over every branch in the collection
77
+ #
78
+ # @overload each
79
+ #
80
+ # @example Get an enumerator over all branches
81
+ # enum = repo.branches.each
82
+ #
83
+ # @return [Enumerator<Git::Branch>] an enumerator over all branches
84
+ #
85
+ # @overload each(&block)
86
+ #
87
+ # @example Print every branch name
88
+ # repo.branches.each { |b| puts b.name }
89
+ #
90
+ # @return [Array<Git::Branch>] the full list of branches
91
+ #
92
+ # @yield [branch] passes each branch to the block
93
+ #
94
+ # @yieldparam branch [Git::Branch] a branch in the repository
95
+ #
96
+ # @yieldreturn [void]
97
+ #
32
98
  def each(&)
33
99
  @branches.values.each(&)
34
100
  end
35
101
 
36
- # Returns the target branch
102
+ # Returns the branch with the given name
103
+ #
104
+ # Supports short names (`'main'`), remote-qualified names
105
+ # (`'working/master'`), and full refspec names
106
+ # (`'remotes/working/master'`).
37
107
  #
38
- # Example:
39
- # Given (git branch -a):
40
- # master
41
- # remotes/working/master
108
+ # @example Look up a branch by short name
109
+ # repo.branches['main']
42
110
  #
43
- # g.branches['master'].full #=> 'master'
44
- # g.branches['working/master'].full => 'remotes/working/master'
45
- # g.branches['remotes/working/master'].full => 'remotes/working/master'
111
+ # @example Look up a remote-tracking branch
112
+ # repo.branches['working/master']
113
+ #
114
+ # @param branch_name [#to_s] the name of the branch to retrieve
115
+ #
116
+ # @return [Git::Branch, nil] the matching branch, or `nil` if not found
46
117
  #
47
- # @param [#to_s] branch_name the target branch name.
48
- # @return [Git::Branch] the target branch.
49
118
  def [](branch_name)
50
- @branches.values.each_with_object(@branches) do |branch, branches|
51
- branches[branch.full] ||= branch
52
-
53
- # This is how Git (version 1.7.9.5) works.
54
- # Lets you ignore the 'remotes' if its at the beginning of the branch full
55
- # name (even if is not a real remote branch).
56
- branches[branch.full.sub('remotes/', '')] ||= branch if branch.full =~ %r{^remotes/.+}
57
- end[branch_name.to_s]
119
+ @lookup[branch_name.to_s]
58
120
  end
59
121
 
122
+ # Returns a string listing all branches, prefixed with `*` for the current branch
123
+ #
124
+ # @example Display all branches
125
+ # puts repo.branches.to_s
126
+ #
127
+ # @return [String] a formatted branch listing
128
+ #
60
129
  def to_s
61
- out = ''
130
+ out = +''
62
131
  @branches.each_value do |b|
63
132
  out << (b.current ? '* ' : ' ') << b.to_s << "\n"
64
133
  end
65
134
  out
66
135
  end
136
+
137
+ private
138
+
139
+ # Resolves the {Git::Repository} for this collection of branches
140
+ #
141
+ # Accepts either a {Git::Repository} (new form) or a {Git::Base} (legacy).
142
+ # The `is_a?(Git::Base)` guard will be removed when {Git::Base} is deleted
143
+ # in Phase 4.
144
+ #
145
+ # @return [Git::Repository] the repository used to enumerate branches
146
+ #
147
+ # @api private
148
+ #
149
+ def branch_repository
150
+ @base.is_a?(Git::Base) ? @base.facade_repository : @base
151
+ end
152
+
153
+ # Indexes all supported lookup keys for a branch without mutating
154
+ # the canonical `@branches` collection used by enumeration
155
+ #
156
+ # @param branch [Git::Branch] the branch to index
157
+ #
158
+ # @param refname [String] the full refname key to use for primary lookup
159
+ #
160
+ # @return [void]
161
+ #
162
+ # @api private
163
+ #
164
+ def index_branch_lookup(branch, refname:)
165
+ @lookup[refname] ||= branch
166
+ @lookup[branch.full] ||= branch
167
+
168
+ return unless branch.full.start_with?('remotes/')
169
+
170
+ # Mirror git compatibility: allow omitting a leading "remotes/".
171
+ @lookup[branch.full.delete_prefix('remotes/')] ||= branch
172
+ end
67
173
  end
68
174
  end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/command_line'
4
+ require 'git/errors'
5
+
6
+ module Git
7
+ module CommandLine
8
+ # Abstract base class for git command-line execution strategies
9
+ #
10
+ # Concrete subclasses must implement {#run} to execute a git command and
11
+ # return a {Git::CommandLine::Result}. Two implementations are provided:
12
+ #
13
+ # * {Git::CommandLine::Capturing} — buffers stdout and stderr in memory
14
+ # * {Git::CommandLine::Streaming} — streams stdout to a caller-supplied IO
15
+ #
16
+ # @example Instantiate a concrete subclass
17
+ # env = { 'GIT_DIR' => '/path/to/git/dir' }
18
+ # binary_path = '/usr/bin/git'
19
+ # global_opts = %w[--git-dir /path/to/git/dir]
20
+ # logger = Logger.new($stdout)
21
+ # cli = Git::CommandLine::Capturing.new(env, binary_path, global_opts, logger)
22
+ # cli.run('version') #=> #<Git::CommandLine::Result ...>
23
+ #
24
+ # @abstract Subclass and implement {#run}
25
+ #
26
+ # @api public
27
+ #
28
+ class Base
29
+ # Create a Base (or subclass) object
30
+ #
31
+ # @param env [Hash{String => String, nil}] environment variables to set or
32
+ # unset. String values set the variable; `nil` values unset it.
33
+ #
34
+ # @param binary_path [String] the path to the git binary
35
+ #
36
+ # @param global_opts [Array<String>] global options to pass to git
37
+ #
38
+ # @param logger [Logger] the logger to use
39
+ #
40
+ def initialize(env, binary_path, global_opts, logger)
41
+ @env = env
42
+ @binary_path = binary_path
43
+ @global_opts = global_opts
44
+ @logger = logger
45
+ end
46
+
47
+ # Execute a git command and return the result
48
+ #
49
+ # Concrete subclasses must override this method.
50
+ #
51
+ # @raise [NotImplementedError] always — must be implemented by subclasses
52
+ #
53
+ def run(*)
54
+ raise NotImplementedError, "#{self.class}#run is not implemented"
55
+ end
56
+
57
+ # @attribute [r] env
58
+ #
59
+ # Variables to set (or unset) in the git command's environment
60
+ #
61
+ # @example
62
+ # env = { 'GIT_DIR' => '/path/to/git/dir' }
63
+ # cli = Git::CommandLine::Capturing.new(env, '/usr/bin/git', [], Logger.new(nil))
64
+ # cli.env #=> { 'GIT_DIR' => '/path/to/git/dir' }
65
+ #
66
+ # @return [Hash{String => String, nil}]
67
+ #
68
+ # @see https://ruby-doc.org/3.2.1/Process.html#method-c-spawn Process.spawn
69
+ # for details on how to set environment variables using the `env` parameter
70
+ #
71
+ attr_reader :env
72
+
73
+ # @attribute [r] binary_path
74
+ #
75
+ # The path to the command line binary to run
76
+ #
77
+ # @example
78
+ # cli = Git::CommandLine::Capturing.new({}, '/usr/bin/git', [], Logger.new(nil))
79
+ # cli.binary_path #=> '/usr/bin/git'
80
+ #
81
+ # @return [String]
82
+ #
83
+ attr_reader :binary_path
84
+
85
+ # @attribute [r] global_opts
86
+ #
87
+ # The global options to pass to git
88
+ #
89
+ # These are options that are passed to git before the command name and
90
+ # arguments. For example, in `git --git-dir /path/to/git/dir version`, the
91
+ # global options are %w[--git-dir /path/to/git/dir].
92
+ #
93
+ # @example
94
+ # global_opts = %w[--git-dir /path/to/git/dir]
95
+ # cli = Git::CommandLine::Capturing.new({}, '/usr/bin/git', global_opts, Logger.new(nil))
96
+ # cli.global_opts #=> %w[--git-dir /path/to/git/dir]
97
+ #
98
+ # @return [Array<String>]
99
+ #
100
+ attr_reader :global_opts
101
+
102
+ # @attribute [r] logger
103
+ #
104
+ # The logger to use for logging git commands and results
105
+ #
106
+ # @example
107
+ # logger = Logger.new(nil)
108
+ # cli = Git::CommandLine::Capturing.new({}, '/usr/bin/git', [], logger)
109
+ # cli.logger == logger #=> true
110
+ #
111
+ # @return [Logger]
112
+ #
113
+ attr_reader :logger
114
+
115
+ private
116
+
117
+ # Merge caller-supplied options into `defaults` and raise if any unknown keys are present
118
+ #
119
+ # @param defaults [Hash] the allowed keys and their default values (e.g. RUN_OPTION_DEFAULTS)
120
+ #
121
+ # @param options_hash [Hash] caller-supplied options
122
+ #
123
+ # @return [Hash] defaults with any supplied values overridden
124
+ #
125
+ # @raise [ArgumentError] if options_hash contains keys not present in defaults
126
+ #
127
+ # @api private
128
+ #
129
+ def merge_and_validate_options(defaults, options_hash)
130
+ merged = defaults.merge(options_hash)
131
+ extra = merged.keys - defaults.keys
132
+ raise ArgumentError, "Unknown options: #{extra.join(', ')}" if extra.any?
133
+
134
+ merged
135
+ end
136
+
137
+ # Merge the instance-level env with any per-call overrides in options_hash[:env]
138
+ #
139
+ # @param options_hash [Hash] options that may include an :env override
140
+ #
141
+ # @return [Hash{String => String, nil}]
142
+ #
143
+ # @api private
144
+ #
145
+ def merged_env(options_hash)
146
+ env.merge(options_hash[:env] || {})
147
+ end
148
+
149
+ # Yield to a block that calls ProcessExecuter and translate any ProcessExecuter
150
+ # errors to their ruby-git equivalents
151
+ #
152
+ # @raise [ArgumentError] in place of ProcessExecuter::ArgumentError
153
+ #
154
+ # @raise [Git::Error] in place of ProcessExecuter::SpawnError (binary not found or
155
+ # failed to launch)
156
+ #
157
+ # @raise [Git::ProcessIOError] in place of ProcessExecuter::ProcessIOError
158
+ #
159
+ # @return [Object] the return value of the block
160
+ #
161
+ # @api private
162
+ #
163
+ def run_process_executer
164
+ yield
165
+ rescue ProcessExecuter::ArgumentError => e
166
+ raise ::ArgumentError, e.message
167
+ rescue ProcessExecuter::SpawnError => e
168
+ raise Git::Error, e.message, cause: e.cause
169
+ rescue ProcessExecuter::ProcessIOError => e
170
+ raise Git::ProcessIOError, e.message, cause: e.cause
171
+ end
172
+
173
+ # Build the git command line from the available sources to send to `Process.spawn`
174
+ #
175
+ # @param args [Array<String>] command-line arguments to append after global options
176
+ #
177
+ # @return [Array<String>]
178
+ #
179
+ # @raise [ArgumentError] if any element of args is itself an Array
180
+ #
181
+ # @api private
182
+ #
183
+ def build_git_cmd(args)
184
+ raise ArgumentError, 'The args array can not contain an array' if args.any?(Array)
185
+
186
+ [binary_path, *global_opts, *args].map(&:to_s)
187
+ end
188
+
189
+ # Log the result of a git command at info/debug level
190
+ #
191
+ # @param result [ProcessExecuter::Result] the raw process result
192
+ #
193
+ # @param command [Array<String>] the full command that was run
194
+ #
195
+ # @param processed_out [String] the post-processed stdout string
196
+ #
197
+ # @param processed_err [String] the post-processed stderr string
198
+ #
199
+ # @return [void]
200
+ #
201
+ # @api private
202
+ #
203
+ def log_result(result, command, processed_out, processed_err)
204
+ logger.info { "#{command} exited with status #{result}" }
205
+ logger.debug { "stdout:\n#{processed_out.inspect}\nstderr:\n#{processed_err.inspect}" }
206
+ end
207
+
208
+ # Build a {Git::CommandLine::Result} and raise on timeout, signal, or failure
209
+ #
210
+ # @param command [Array<String>] the full command that was run
211
+ #
212
+ # @param result [ProcessExecuter::Result] the raw process result
213
+ #
214
+ # @param processed_out [String] processed stdout string
215
+ #
216
+ # @param processed_err [String] processed stderr string
217
+ #
218
+ # @param timeout [Numeric, nil] the timeout value (for error context)
219
+ #
220
+ # @param raise_on_failure [Boolean] whether to raise on non-zero exit status
221
+ #
222
+ # @return [Git::CommandLine::Result]
223
+ #
224
+ # @raise [Git::TimeoutError] if the command timed out
225
+ #
226
+ # @raise [Git::SignaledError] if the command was terminated by a signal
227
+ #
228
+ # @raise [Git::FailedError] if the command failed and raise_on_failure is true
229
+ #
230
+ # @api private
231
+ #
232
+ # rubocop:disable Metrics/ParameterLists
233
+ def command_line_result(command, result, processed_out, processed_err, timeout, raise_on_failure)
234
+ Git::CommandLine::Result.new(command, result, processed_out, processed_err).tap do |processed_result|
235
+ raise Git::TimeoutError.new(processed_result, timeout) if result.timed_out?
236
+
237
+ raise Git::SignaledError, processed_result if result.signaled?
238
+
239
+ raise Git::FailedError, processed_result if raise_on_failure && !result.success?
240
+ end
241
+ end
242
+ # rubocop:enable Metrics/ParameterLists
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/command_line'
4
+
5
+ module Git
6
+ module CommandLine
7
+ # Executes a git command and captures both stdout and stderr in memory
8
+ #
9
+ # {Git::CommandLine::Capturing} is the buffering strategy: it calls
10
+ # `ProcessExecuter.run_with_capture`, which reads all subprocess output into
11
+ # `String` objects before returning. Use this class (via
12
+ # {Git::Lib#command_capturing}) for the vast majority of git subcommands whose
13
+ # output fits comfortably in memory.
14
+ #
15
+ # {Git::CommandLine::Streaming} is the complementary strategy for commands
16
+ # (such as `cat-file -p <blob>`) whose stdout may be too large to buffer.
17
+ #
18
+ # @example
19
+ # capturing = Git::CommandLine::Capturing.new(
20
+ # {}, '/usr/bin/git', %w[--git-dir /repo/.git], Logger.new($stdout)
21
+ # )
22
+ # result = capturing.run('log', '--oneline', '-5')
23
+ # result.stdout # => "abc1234 Initial commit\n..."
24
+ # result.stderr # => ""
25
+ #
26
+ # @see Git::Lib#command_capturing
27
+ #
28
+ # @see Git::CommandLine::Streaming
29
+ #
30
+ class Capturing < Git::CommandLine::Base
31
+ # Default options accepted by {#run}
32
+ #
33
+ # @api private
34
+ RUN_OPTION_DEFAULTS = {
35
+ in: nil,
36
+ out: nil,
37
+ err: nil,
38
+ chdir: nil,
39
+ timeout: nil,
40
+ raise_on_failure: true,
41
+ env: {},
42
+ normalize: false,
43
+ chomp: false,
44
+ merge: false
45
+ }.freeze
46
+
47
+ # Execute a git command, capture stdout and stderr, and return the result
48
+ #
49
+ # Non-option command-line arguments to pass to git. If you collect the
50
+ # arguments in an array, splat the array into the parameter list.
51
+ #
52
+ # NORMALIZATION
53
+ #
54
+ # The command output is returned as a Unicode string containing the binary
55
+ # output from the command. If the binary output is not valid UTF-8, the
56
+ # output will cause problems because the encoding will be invalid.
57
+ #
58
+ # Normalization is a process that tries to convert the binary output to a
59
+ # valid UTF-8 string. It uses the `rchardet` gem to detect the encoding of
60
+ # the binary output and then converts it to UTF-8.
61
+ #
62
+ # Normalization is not enabled by default. Pass `normalize: true` to enable
63
+ # it. When enabled, normalization is applied to both stdout and stderr in
64
+ # the returned result object, regardless of the `out:` or `err:` options.
65
+ # Only the captured in-memory strings are normalized; any external IO you
66
+ # provide will receive the raw subprocess output.
67
+ #
68
+ # @example Run a command and return the output
69
+ # result = capturing.run('version')
70
+ # result.stdout #=> "git version 2.39.1\n"
71
+ #
72
+ # @example The args array should be splatted into the parameter list
73
+ # args = %w[log -n 1 --oneline]
74
+ # result = capturing.run(*args)
75
+ # result.stdout #=> "f5baa11 beginning of Ruby/Git project\n"
76
+ #
77
+ # @example Run a command and return the chomped output
78
+ # result = capturing.run('version', chomp: true)
79
+ # result.stdout #=> "git version 2.39.1"
80
+ #
81
+ # @example Run a command without normalizing the output
82
+ # capturing.run('version', normalize: false) #=> "git version 2.39.1\n"
83
+ #
84
+ # @example Capture stdout in a temporary file
85
+ # require 'tempfile'
86
+ # Tempfile.create('git') do |file|
87
+ # capturing.run('version', out: file)
88
+ # file.rewind
89
+ # file.read #=> "git version 2.39.1\n"
90
+ # end
91
+ #
92
+ # @example Capture stderr in a StringIO object
93
+ # require 'stringio'
94
+ # stderr = StringIO.new
95
+ # begin
96
+ # capturing.run('log', 'nonexistent-branch', err: stderr)
97
+ # rescue Git::FailedError => e
98
+ # stderr.string #=> "unknown revision or path not in the working tree.\n"
99
+ # end
100
+ #
101
+ # @param options_hash [Hash] the options to pass to the command
102
+ #
103
+ # @option options_hash [IO, nil] :in the IO object to use as stdin for the
104
+ # command, or nil to inherit the parent process stdin. Must be a real IO
105
+ # object with a file descriptor (not StringIO).
106
+ #
107
+ # @option options_hash [#write, nil] :out the object to write stdout to, or
108
+ # nil to capture stdout in the returned result.
109
+ #
110
+ # If this is a `StringIO` object, `stdout_writer.string` will be returned.
111
+ #
112
+ # In general, only specify a `stdout_writer` when you want to redirect
113
+ # stdout to a file or other `#write`-responding object. The default
114
+ # behaviour returns the command output.
115
+ #
116
+ # @option options_hash [#write, nil] :err the object to write stderr to, or
117
+ # nil to capture stderr in the returned result.
118
+ #
119
+ # @option options_hash [Boolean] :normalize (false) whether to normalize the
120
+ # encoding of stdout and stderr output
121
+ #
122
+ # @option options_hash [Boolean] :chomp (false) whether to chomp both stdout
123
+ # and stderr output
124
+ #
125
+ # @option options_hash [Boolean] :merge (false) whether to merge stdout and
126
+ # stderr in the returned string
127
+ #
128
+ # @option options_hash [String, nil] :chdir the directory to run the command in
129
+ #
130
+ # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for
131
+ # the command to complete. Zero means no timeout. A timeout kills the
132
+ # process via `SIGKILL` and raises {Git::TimeoutError}.
133
+ #
134
+ # @option options_hash [Boolean] :raise_on_failure (true) whether to raise
135
+ # {Git::FailedError} on non-zero exit status.
136
+ # {Git::TimeoutError} and {Git::SignaledError} are always raised regardless.
137
+ #
138
+ # @option options_hash [Hash] :env ({}) additional environment variable
139
+ # overrides for this command. String keys map to String values (to set) or
140
+ # `nil` (to unset).
141
+ #
142
+ # @return [Git::CommandLineResult] the result of the command
143
+ #
144
+ # @raise [ArgumentError] if `args` contains an array or an unknown option is
145
+ # passed
146
+ #
147
+ # @raise [Git::SignaledError] if the command was terminated by an uncaught signal
148
+ #
149
+ # @raise [Git::FailedError] if the command returned a non-zero exit status
150
+ #
151
+ # @raise [Git::ProcessIOError] if an exception was raised while collecting
152
+ # subprocess output
153
+ #
154
+ # @raise [Git::TimeoutError] if the command times out
155
+ #
156
+ def run(*, **options_hash)
157
+ options = merge_and_validate_options(RUN_OPTION_DEFAULTS, options_hash)
158
+
159
+ result = execute(*, **options)
160
+ process_result(result, options)
161
+ end
162
+
163
+ private
164
+
165
+ # @return [ProcessExecuter::ResultWithCapture] the process result with captured output
166
+ #
167
+ # @api private
168
+ def execute(*args, **options_hash)
169
+ git_cmd = build_git_cmd(args)
170
+ options = execute_options(**options_hash)
171
+ run_process_executer do
172
+ ProcessExecuter.run_with_capture(merged_env(options_hash), *git_cmd, **options)
173
+ end
174
+ end
175
+
176
+ # Build the ProcessExecuter options hash for a capturing run
177
+ #
178
+ # @return [Hash]
179
+ #
180
+ # @api private
181
+ def execute_options(**options_hash)
182
+ chdir = options_hash[:chdir] || :not_set
183
+ timeout_after = options_hash[:timeout]
184
+ merge_output = options_hash[:merge] || false
185
+
186
+ { chdir:, timeout_after:, merge_output:, raise_errors: false }.tap do |options|
187
+ redirect_options(options_hash).each { |k, v| options[k] = v }
188
+ end
189
+ end
190
+
191
+ # Extract non-nil redirect options (`:in`, `:out`, `:err`) from options_hash
192
+ #
193
+ # @return [Hash]
194
+ #
195
+ # @api private
196
+ def redirect_options(options_hash)
197
+ %i[in out err].filter_map do |key|
198
+ val = options_hash[key]
199
+ [key, val] unless val.nil?
200
+ end.to_h
201
+ end
202
+
203
+ # Post-process and return the stdout/stderr strings from the captured result,
204
+ # then log and raise on failure if required.
205
+ #
206
+ # @param result [ProcessExecuter::ResultWithCapture] the raw result
207
+ #
208
+ # @param options [Hash] the merged run options
209
+ #
210
+ # @return [Git::CommandLineResult]
211
+ #
212
+ # @raise [Git::FailedError] if the command failed and raise_on_failure is true
213
+ #
214
+ # @raise [Git::SignaledError] if the command was signaled
215
+ #
216
+ # @raise [Git::TimeoutError] if the command timed out
217
+ #
218
+ # @api private
219
+ def process_result(result, options)
220
+ command = result.command
221
+ processed_out, processed_err = post_process_output(result, options[:normalize], options[:chomp])
222
+ log_result(result, command, processed_out, processed_err)
223
+ command_line_result(
224
+ command, result, processed_out, processed_err, options[:timeout], options[:raise_on_failure]
225
+ )
226
+ end
227
+
228
+ # Normalize and/or chomp the raw stdout and stderr strings.
229
+ #
230
+ # @param result [ProcessExecuter::ResultWithCapture] the raw result
231
+ #
232
+ # @param normalize [Boolean]
233
+ #
234
+ # @param chomp [Boolean]
235
+ #
236
+ # @return [Array<String>] two-element array: [processed_stdout, processed_stderr]
237
+ #
238
+ # @api private
239
+ def post_process_output(result, normalize, chomp)
240
+ [result.stdout, result.stderr].map do |raw_output|
241
+ output = raw_output.dup
242
+ output = output.lines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join if normalize
243
+ output.chomp! if chomp
244
+ output
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end