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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/fsck_object'
4
+ require 'git/fsck_result'
5
+
6
+ module Git
7
+ module Parsers
8
+ # Parser for git fsck command output
9
+ #
10
+ # Handles parsing of `git fsck` output into structured data objects
11
+ # for dangling, missing, unreachable objects, warnings, roots, and tagged objects.
12
+ #
13
+ # ## Design Note: Namespace Organization
14
+ #
15
+ # This parser creates and returns {Git::FsckObject} and {Git::FsckResult}
16
+ # objects, which live at the top-level `Git::` namespace rather than within
17
+ # `Git::Parsers::`. This is intentional:
18
+ #
19
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
20
+ # interact with them directly
21
+ # - **Result classes are public API** - returned by commands and used
22
+ # throughout the codebase
23
+ # - **FsckObject and FsckResult represent operation outcomes** - they describe
24
+ # repository integrity status, not parsing details
25
+ #
26
+ # Keeping these classes at `Git::` improves discoverability and correctly
27
+ # reflects their role as public types rather than parser internals.
28
+ #
29
+ # @api private
30
+ #
31
+ module Fsck
32
+ # Pattern matcher for dangling/missing/unreachable object lines
33
+ # Matches lines like:
34
+ # dangling commit abc123...
35
+ # missing blob def456...
36
+ # unreachable tree 789abc... (name)
37
+ OBJECT_PATTERN = /\A(dangling|missing|unreachable) (\w+) ([0-9a-f]{40})(?: \((.+)\))?\z/
38
+
39
+ # Pattern matcher for warning lines
40
+ # Matches lines like:
41
+ # warning in commit abc123...: message here
42
+ WARNING_PATTERN = /\Awarning in (\w+) ([0-9a-f]{40}): (.+)\z/
43
+
44
+ # Pattern matcher for root commit lines
45
+ # Matches lines like:
46
+ # root abc123...
47
+ ROOT_PATTERN = /\Aroot ([0-9a-f]{40})\z/
48
+
49
+ # Pattern matcher for tagged object lines
50
+ # Matches lines like:
51
+ # tagged commit abc123... (tagname) in def456...
52
+ TAGGED_PATTERN = /\Atagged (\w+) ([0-9a-f]{40}) \((.+)\) in ([0-9a-f]{40})\z/
53
+
54
+ module_function
55
+
56
+ # Parse git fsck output into a FsckResult object
57
+ #
58
+ # @example
59
+ # FsckParser.parse("dangling commit abc123...\nmissing blob def456...\n")
60
+ # # => #<Git::FsckResult dangling: [...], missing: [...]>
61
+ #
62
+ # @param stdout [String] output from git fsck command
63
+ # @return [Git::FsckResult] the parsed result
64
+ #
65
+ def parse(stdout)
66
+ result = { dangling: [], missing: [], unreachable: [], warnings: [], root: [], tagged: [] }
67
+ stdout.each_line { |line| parse_line(line.strip, result) }
68
+ Git::FsckResult.new(**result)
69
+ end
70
+
71
+ # Parse a single line of fsck output
72
+ #
73
+ # @param line [String] a line of output
74
+ # @param result [Hash] the result hash to populate
75
+ # @return [Boolean] true if the line was parsed
76
+ #
77
+ def parse_line(line, result)
78
+ parse_object_line(line, result) ||
79
+ parse_warning_line(line, result) ||
80
+ parse_root_line(line, result) ||
81
+ parse_tagged_line(line, result)
82
+ end
83
+
84
+ # Parse a dangling/missing/unreachable object line
85
+ #
86
+ # @param line [String] a line of output
87
+ # @param result [Hash] the result hash to populate
88
+ # @return [Boolean] true if the line was parsed
89
+ #
90
+ def parse_object_line(line, result)
91
+ return unless (match = OBJECT_PATTERN.match(line))
92
+
93
+ result[match[1].to_sym] << Git::FsckObject.new(type: match[2].to_sym, oid: match[3], name: match[4])
94
+ end
95
+
96
+ # Parse a warning line
97
+ #
98
+ # @param line [String] a line of output
99
+ # @param result [Hash] the result hash to populate
100
+ # @return [Boolean] true if the line was parsed
101
+ #
102
+ def parse_warning_line(line, result)
103
+ return unless (match = WARNING_PATTERN.match(line))
104
+
105
+ result[:warnings] << Git::FsckObject.new(type: match[1].to_sym, oid: match[2], message: match[3])
106
+ end
107
+
108
+ # Parse a root line
109
+ #
110
+ # @param line [String] a line of output
111
+ # @param result [Hash] the result hash to populate
112
+ # @return [Boolean] true if the line was parsed
113
+ #
114
+ def parse_root_line(line, result)
115
+ return unless (match = ROOT_PATTERN.match(line))
116
+
117
+ result[:root] << Git::FsckObject.new(type: :commit, oid: match[1])
118
+ end
119
+
120
+ # Parse a tagged line
121
+ #
122
+ # @param line [String] a line of output
123
+ # @param result [Hash] the result hash to populate
124
+ # @return [Boolean] true if the line was parsed
125
+ #
126
+ def parse_tagged_line(line, result)
127
+ return unless (match = TAGGED_PATTERN.match(line))
128
+
129
+ result[:tagged] << Git::FsckObject.new(type: match[1].to_sym, oid: match[2], name: match[3])
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Parsers
5
+ # Parser for `git grep` output
6
+ #
7
+ # Provides a class method that transforms raw `git grep --null` output into a
8
+ # structured Hash consumed by the `Git::Repository::ObjectOperations` facade.
9
+ #
10
+ # This parser is a pure text transformer with no exit-status logic. The
11
+ # calling facade is responsible for interpreting the command's exit status
12
+ # before delegating output parsing to this class.
13
+ #
14
+ # @api private
15
+ #
16
+ module Grep
17
+ module_function
18
+
19
+ # Parse `git grep --line-number --null --no-color` output into a match hash
20
+ #
21
+ # With `--null`, git separates the path and line number fields with NUL
22
+ # bytes: `treeish:filename\0linenum\0text\n`. This keeps filenames that
23
+ # contain `:<digits>:` from being confused with the line-number delimiter.
24
+ #
25
+ # @param output [String] raw output from `git grep --null --line-number`
26
+ #
27
+ # @return [Hash<String, Array<Array(Integer, String)>>] hash mapping
28
+ # `"treeish:filename"` keys to arrays of `[line_number, text]` pairs
29
+ #
30
+ # @api private
31
+ #
32
+ def parse(output)
33
+ output.each_line.with_object(Hash.new { |h, k| h[k] = [] }) do |line, hsh|
34
+ filename, line_num, text = line.chomp.split("\0", 3)
35
+ next unless text && line_num&.match?(/\A\d+\z/)
36
+
37
+ hsh[filename] << [line_num.to_i, text]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/escaped_path'
4
+
5
+ module Git
6
+ module Parsers
7
+ # Parser for `git ls-tree` output
8
+ #
9
+ # Provides a class method that transforms raw `git ls-tree` output into a
10
+ # structured Hash consumed by the `Git::Repository::ObjectOperations` facade.
11
+ #
12
+ # @api private
13
+ #
14
+ module LsTree
15
+ module_function
16
+
17
+ # Parse `git ls-tree` output into a type-keyed hash of entries
18
+ #
19
+ # Each line of output is expected in the format produced by
20
+ # `git ls-tree`: `<mode> <type> <sha>\t<file>`.
21
+ #
22
+ # @param output [String] raw stdout from `git ls-tree`
23
+ #
24
+ # @return [Hash<String, Hash<String, Hash>>] hash keyed by object type
25
+ # (`'blob'`, `'tree'`, `'commit'`), then by filename, holding
26
+ # `:mode` and `:sha` values
27
+ #
28
+ # @api private
29
+ #
30
+ def parse(output)
31
+ data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
32
+ output.split("\n").each do |line|
33
+ info, filenm = line.split("\t", 2)
34
+ filenm = unescape_path(filenm) if filenm
35
+ mode, type, entry_sha = info.split
36
+ data[type][filenm] = { mode: mode, sha: entry_sha }
37
+ end
38
+ data
39
+ end
40
+
41
+ # Converts a git-quoted path back to its original form
42
+ #
43
+ # @param path [String] the path, possibly git-quoted
44
+ #
45
+ # @return [String] the unquoted path
46
+ #
47
+ # @api private
48
+ #
49
+ def unescape_path(path)
50
+ if path.start_with?('"') && path.end_with?('"')
51
+ Git::EscapedPath.new(path[1..-2]).unescape
52
+ else
53
+ path
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/stash_info'
4
+
5
+ module Git
6
+ module Parsers
7
+ # Parser for git stash command output
8
+ #
9
+ # Handles parsing of `git stash list` output into structured data objects.
10
+ #
11
+ # @note Known limitation: If a stash message contains the field separator
12
+ # character (\x1f, ASCII unit separator), parsing will fail or produce
13
+ # incorrect results. This is extremely rare in practice since \x1f is a
14
+ # non-printable control character.
15
+ #
16
+ # ## Design Note: Namespace Organization
17
+ #
18
+ # This parser creates and returns {Git::StashInfo} objects, which live at
19
+ # the top-level `Git::` namespace rather than within `Git::Parsers::`. This
20
+ # is intentional:
21
+ #
22
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
23
+ # interact with them directly
24
+ # - **Info classes are public API** - returned by commands and used throughout
25
+ # the codebase
26
+ # - **Info classes are domain entities** - represent core git concepts
27
+ # (stashes as data)
28
+ #
29
+ # Keeping Info classes at `Git::` improves discoverability and correctly
30
+ # reflects their role as public types rather than parser internals.
31
+ #
32
+ # @api private
33
+ #
34
+ module Stash
35
+ # Field separator used in custom format output
36
+ # Using a non-printable unit separator (US, 0x1F) to avoid collisions with
37
+ # stash messages and author/committer fields, while still working with
38
+ # Process.spawn (which doesn't allow NUL bytes in arguments)
39
+ FIELD_SEPARATOR = "\x1f"
40
+
41
+ # Custom format for git stash list that extracts all available metadata
42
+ # %H = full commit SHA
43
+ # %h = abbreviated commit SHA
44
+ # %gd = reflog selector (stash@\\{n})
45
+ # %gs = reflog subject (the stash message)
46
+ # %an = author name
47
+ # %ae = author email
48
+ # %aI = author date (ISO 8601 format)
49
+ # %cn = committer name
50
+ # %ce = committer email
51
+ # %cI = committer date (ISO 8601 format)
52
+ STASH_FORMAT = [
53
+ '%H', # 0: full SHA
54
+ '%h', # 1: short SHA
55
+ '%gd', # 2: reflog selector
56
+ '%gs', # 3: reflog subject (message)
57
+ '%an', # 4: author name
58
+ '%ae', # 5: author email
59
+ '%aI', # 6: author date
60
+ '%cn', # 7: committer name
61
+ '%ce', # 8: committer email
62
+ '%cI' # 9: committer date
63
+ ].join(FIELD_SEPARATOR)
64
+
65
+ # Number of fields expected in the parsed output
66
+ FIELD_COUNT = 10
67
+
68
+ # Field indices for parsed output
69
+ module Fields
70
+ OID = 0
71
+ SHORT_OID = 1
72
+ REFLOG = 2
73
+ MESSAGE = 3
74
+ AUTHOR_NAME = 4
75
+ AUTHOR_EMAIL = 5
76
+ AUTHOR_DATE = 6
77
+ COMMITTER_NAME = 7
78
+ COMMITTER_EMAIL = 8
79
+ COMMITTER_DATE = 9
80
+ end
81
+
82
+ # Pattern to extract branch from standard stash messages
83
+ # Matches "WIP on <branch>:" or "On <branch>:" at the start
84
+ BRANCH_PATTERN = /^(?:WIP on|On)\s+([^:]+):/
85
+
86
+ module_function
87
+
88
+ # Parse git stash list output into StashInfo objects
89
+ #
90
+ # @example
91
+ # StashParser.parse_list("abc123\x1fabc\x1fstash@\\{0}\x1fWIP on main: msg\x1f...\n")
92
+ # # => [#<Git::StashInfo index: 0, ...>]
93
+ #
94
+ # @param stdout [String] output from git stash list --format=...
95
+ # @return [Array<Git::StashInfo>] parsed stash information
96
+ #
97
+ # @raise [Git::UnexpectedResultError] if stash output cannot be parsed
98
+ #
99
+ def parse_list(stdout)
100
+ lines = stdout.split("\n")
101
+ lines.each_with_index.map { |line, idx| parse_stash_line(line, idx, lines) }
102
+ end
103
+
104
+ # Parse a single stash list line into a StashInfo object
105
+ #
106
+ # @param line [String] a line from git stash list output (custom format)
107
+ # @param expected_index [Integer] the expected stash index for validation
108
+ # @param all_lines [Array<String>] all output lines (for error messages)
109
+ #
110
+ # @return [Git::StashInfo] parsed stash info
111
+ #
112
+ # @raise [Git::UnexpectedResultError] if line format is unexpected
113
+ #
114
+ def parse_stash_line(line, expected_index, all_lines)
115
+ parts = line.split(FIELD_SEPARATOR, FIELD_COUNT)
116
+ return build_stash_info(parts, expected_index) if parts.length == FIELD_COUNT
117
+
118
+ raise Git::UnexpectedResultError, unexpected_stash_line_error(all_lines, line, expected_index)
119
+ end
120
+
121
+ # Build a StashInfo from parsed format parts
122
+ #
123
+ # @param parts [Array<String>] the parsed format fields
124
+ # @param expected_index [Integer] fallback index if not parseable from reflog
125
+ # @return [Git::StashInfo]
126
+ #
127
+ def build_stash_info(parts, expected_index)
128
+ index = extract_index(parts[Fields::REFLOG]) || expected_index
129
+
130
+ Git::StashInfo.new(**stash_info_attrs(parts, index))
131
+ end
132
+
133
+ # Build StashInfo attributes hash from parsed parts
134
+ #
135
+ # @param parts [Array<String>] the parsed format fields
136
+ # @param index [Integer] the resolved stash index
137
+ # @return [Hash] attributes for StashInfo.new
138
+ #
139
+ def stash_info_attrs(parts, index)
140
+ core_attrs(parts, index).merge(author_attrs(parts)).merge(committer_attrs(parts))
141
+ end
142
+
143
+ def core_attrs(parts, index)
144
+ {
145
+ index: index, name: parts[Fields::REFLOG], oid: parts[Fields::OID],
146
+ short_oid: parts[Fields::SHORT_OID], branch: extract_branch(parts[Fields::MESSAGE]),
147
+ message: parts[Fields::MESSAGE]
148
+ }
149
+ end
150
+
151
+ def author_attrs(parts)
152
+ {
153
+ author_name: parts[Fields::AUTHOR_NAME], author_email: parts[Fields::AUTHOR_EMAIL],
154
+ author_date: parts[Fields::AUTHOR_DATE]
155
+ }
156
+ end
157
+
158
+ def committer_attrs(parts)
159
+ {
160
+ committer_name: parts[Fields::COMMITTER_NAME], committer_email: parts[Fields::COMMITTER_EMAIL],
161
+ committer_date: parts[Fields::COMMITTER_DATE]
162
+ }
163
+ end
164
+
165
+ # Extract the stash index from a reflog selector
166
+ #
167
+ # @param reflog_selector [String] e.g., "stash@\\{0}"
168
+ # @return [Integer, nil] the index or nil if not found
169
+ #
170
+ def extract_index(reflog_selector)
171
+ match = reflog_selector&.match(/stash@\{(\d+)\}/)
172
+ match ? match[1].to_i : nil
173
+ end
174
+
175
+ # Extract the branch name from a stash message
176
+ #
177
+ # @param message [String] the stash message
178
+ # @return [String, nil] the branch name or nil for custom messages
179
+ #
180
+ def extract_branch(message)
181
+ match = BRANCH_PATTERN.match(message)
182
+ match ? match[1] : nil
183
+ end
184
+
185
+ # Generate error message for unexpected stash line format
186
+ #
187
+ # @param lines [Array<String>] all output lines
188
+ # @param line [String] the problematic line
189
+ # @param index [Integer] the stash index
190
+ # @return [String] formatted error message
191
+ #
192
+ def unexpected_stash_line_error(lines, line, index)
193
+ format_str = STASH_FORMAT.gsub(FIELD_SEPARATOR, '<FS>')
194
+ <<~ERROR
195
+ Unexpected line in output from `git stash list --format=#{format_str}`, at index #{index}
196
+
197
+ Expected #{FIELD_COUNT} fields separated by '\\x1f' (unit separator), got #{line.split(FIELD_SEPARATOR, -1).length}
198
+
199
+ Full output:
200
+ #{lines.join("\n ")}
201
+
202
+ Line at index #{index}:
203
+ "#{line}"
204
+ ERROR
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/tag_info'
4
+ require 'git/tag_delete_result'
5
+ require 'git/tag_delete_failure'
6
+
7
+ module Git
8
+ module Parsers
9
+ # Parser for git tag command output
10
+ #
11
+ # Handles parsing of `git tag --list` and `git tag --delete` output
12
+ # into structured data objects.
13
+ #
14
+ # @note Known limitation: If a tag message contains the field delimiter
15
+ # character (\x1f, ASCII unit separator), it will be preserved correctly
16
+ # since the message is the last field. However, messages are rarely crafted
17
+ # with non-printable control characters.
18
+ #
19
+ # ## Design Note: Namespace Organization
20
+ #
21
+ # This parser creates and returns {Git::TagInfo} and {Git::TagDeleteResult}
22
+ # objects, which live at the top-level `Git::` namespace rather than within
23
+ # `Git::Parsers::`. This is intentional:
24
+ #
25
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
26
+ # interact with them directly
27
+ # - **Info/Result classes are public API** - returned by commands and used
28
+ # throughout the codebase
29
+ # - **Info classes are domain entities** - represent core git concepts
30
+ # (tags as data)
31
+ # - **Result classes are operation outcomes** - represent command results,
32
+ # not parsing details
33
+ #
34
+ # Keeping Info/Result classes at `Git::` improves discoverability and correctly
35
+ # reflects their role as public types rather than parser internals.
36
+ #
37
+ # @api private
38
+ #
39
+ module Tag
40
+ # Delimiter for separating fields in git tag --format output
41
+ # Field separator used in custom format output
42
+ # Using the ASCII unit separator (US, 0x1F / "\x1f"), a non-printable character,
43
+ # minimizes the chance of collisions with tag names or messages and remains
44
+ # safe to pass through Process.spawn and shell argument boundaries.
45
+ FIELD_DELIMITER = "\x1f"
46
+
47
+ # Delimiter for separating records (tags) in output
48
+ # Using the ASCII record separator (RS, 0x1E / "\x1e") to delimit complete tag records.
49
+ # This allows multi-line messages (which contain newlines) to be parsed correctly
50
+ # since we split by record separator first, then by field delimiter.
51
+ RECORD_DELIMITER = "\x1e"
52
+
53
+ # Number of fields expected in the parsed output
54
+ FIELD_COUNT = 8
55
+
56
+ # Format string for git tag --format
57
+ #
58
+ # Fields:
59
+ # - %(refname:short) - tag name
60
+ # - %(objectname) - SHA of the tag object (for annotated) or commit (for lightweight)
61
+ # - %(*objectname) - Dereferenced SHA (commit ID for annotated tags, empty for lightweight)
62
+ # - %(objecttype) - 'tag' for annotated tags, target object type (commit/tree/blob/etc.) for lightweight tags
63
+ # - %(taggername) - tagger name (empty for lightweight tags)
64
+ # - %(taggeremail) - tagger email (empty for lightweight tags)
65
+ # - %(taggerdate:iso8601-strict) - tagger date in strict ISO 8601 format
66
+ # - %(contents) - full tag message (can be multi-line)
67
+ #
68
+ # Each tag record is terminated by the RECORD_DELIMITER to allow multi-line messages.
69
+ FORMAT_STRING = [
70
+ '%(refname:short)',
71
+ '%(objectname)',
72
+ '%(*objectname)',
73
+ '%(objecttype)',
74
+ '%(taggername)',
75
+ '%(taggeremail)',
76
+ '%(taggerdate:iso8601-strict)',
77
+ '%(contents)'
78
+ ].join(FIELD_DELIMITER) + RECORD_DELIMITER
79
+
80
+ # Regex to parse successful deletion lines from stdout
81
+ # Matches: Deleted tag 'tagname' (was abc123)
82
+ DELETED_TAG_REGEX = /^Deleted tag '([^']+)'/
83
+
84
+ # Regex to parse error messages from stderr
85
+ # Matches: error: tag 'tagname' not found.
86
+ ERROR_TAG_REGEX = /^error: tag '([^']+)'(.*)$/
87
+
88
+ module_function
89
+
90
+ # Parse git tag --list output into TagInfo objects
91
+ #
92
+ # @example
93
+ # TagParser.parse_list("v1.0.0\x1f...\x1e\n")
94
+ # # => [#<Git::TagInfo name: "v1.0.0", ...>]
95
+ #
96
+ # @param stdout [String] output from git tag --list --format=...
97
+ # @return [Array<Git::TagInfo>] parsed tag information
98
+ #
99
+ # @raise [Git::UnexpectedResultError] if any record has unexpected format
100
+ #
101
+ def parse_list(stdout)
102
+ # Split by record separator
103
+ # Each record may have a leading newline from the previous record's %(contents) output
104
+ # Use lstrip to remove leading whitespace (which includes the newline) from each record
105
+ records = stdout.split(RECORD_DELIMITER).map(&:lstrip).reject(&:empty?)
106
+ records.map.with_index { |record, index| parse_tag_record(record, index, records) }
107
+ end
108
+
109
+ # Parse a single formatted tag record
110
+ #
111
+ # The record format is:
112
+ # name<FS>sha<FS>deref<FS>objecttype<FS>tagger_name<FS>tagger_email<FS>tagger_date<FS>message
113
+ # where <FS> is the unit separator character ("\x1f").
114
+ #
115
+ # For lightweight tags, Git emits empty strings for the tagger fields and message;
116
+ # these are converted to nil by {#parse_optional_field} and {#parse_message}.
117
+ #
118
+ # @param record [String] a single tag record from git tag --format output
119
+ # @param index [Integer] record index for error reporting
120
+ # @param all_records [Array<String>] all output records for error messages
121
+ # @return [Git::TagInfo] tag info with all fields populated
122
+ #
123
+ # @raise [Git::UnexpectedResultError] if record format is unexpected
124
+ #
125
+ def parse_tag_record(record, index, all_records)
126
+ parts = record.split(FIELD_DELIMITER, FIELD_COUNT)
127
+
128
+ unless parts.length == FIELD_COUNT
129
+ raise Git::UnexpectedResultError, unexpected_tag_record_error(all_records, record, index)
130
+ end
131
+
132
+ build_tag_info(parts)
133
+ end
134
+
135
+ # Build a TagInfo object from parsed parts
136
+ #
137
+ # @param parts [Array<String>] the parsed format fields
138
+ # @return [Git::TagInfo]
139
+ #
140
+ # @note For annotated tags:
141
+ # - oid = %(objectname) (the tag object's ID)
142
+ # - target_oid = %(*objectname) (the dereferenced commit ID)
143
+ #
144
+ # @note For lightweight tags:
145
+ # - oid = nil (lightweight tags are not objects)
146
+ # - target_oid = %(objectname) (the commit ID)
147
+ #
148
+ def build_tag_info(parts)
149
+ oid, target_oid = resolve_oids(parts[3], parts[1], parts[2])
150
+ build_tag_info_object(parts, oid, target_oid)
151
+ end
152
+
153
+ def resolve_oids(objecttype, objectname, dereferenced)
154
+ objecttype == 'tag' ? [objectname, dereferenced] : [nil, objectname]
155
+ end
156
+
157
+ def build_tag_info_object(parts, oid, target_oid)
158
+ Git::TagInfo.new(
159
+ name: parts[0], oid: oid, target_oid: target_oid, objecttype: parts[3],
160
+ tagger_name: parse_optional_field(parts[4]), tagger_email: parse_optional_field(parts[5]),
161
+ tagger_date: parse_optional_field(parts[6]), message: parse_message(parts[3], parts[7])
162
+ )
163
+ end
164
+
165
+ # Parse an optional field, returning nil if empty
166
+ #
167
+ # @param value [String] the field value
168
+ # @return [String, nil] the value or nil if empty
169
+ #
170
+ def parse_optional_field(value)
171
+ value.empty? ? nil : value
172
+ end
173
+
174
+ # Parse message field, returning nil for lightweight tags or empty messages
175
+ # Strips trailing newlines that git adds to %(contents) output
176
+ #
177
+ # @param objecttype [String] the object type ('tag' or 'commit')
178
+ # @param message [String] the raw message field
179
+ # @return [String, nil] the message or nil
180
+ #
181
+ def parse_message(objecttype, message)
182
+ stripped = message.chomp
183
+ objecttype == 'tag' && !stripped.empty? ? stripped : nil
184
+ end
185
+
186
+ # Parse deleted tag names from stdout
187
+ #
188
+ # @example
189
+ # TagParser.parse_deleted_tags("Deleted tag 'v1.0.0' (was abc123)\n")
190
+ # # => ["v1.0.0"]
191
+ #
192
+ # @param stdout [String] command stdout
193
+ # @return [Array<String>] names of successfully deleted tags
194
+ #
195
+ def parse_deleted_tags(stdout)
196
+ stdout.scan(DELETED_TAG_REGEX).flatten
197
+ end
198
+
199
+ # Parse error messages from stderr into a map
200
+ #
201
+ # @example
202
+ # TagParser.parse_error_messages("error: tag 'missing' not found.\n")
203
+ # # => {"missing" => "error: tag 'missing' not found."}
204
+ #
205
+ # @param stderr [String] command stderr
206
+ # @return [Hash<String, String>] map of tag name to error message
207
+ #
208
+ def parse_error_messages(stderr)
209
+ stderr.each_line.with_object({}) do |line, hash|
210
+ match = line.match(ERROR_TAG_REGEX)
211
+ hash[match[1]] = line.strip if match
212
+ end
213
+ end
214
+
215
+ # Build the TagDeleteResult from parsed data
216
+ #
217
+ # @param requested_names [Array<String>] originally requested tag names
218
+ # @param existing_tags [Hash<String, Git::TagInfo>] tags that existed before delete
219
+ # @param deleted_names [Array<String>] names confirmed deleted in stdout
220
+ # @param error_map [Hash<String, String>] map of tag name to error message
221
+ # @return [Git::TagDeleteResult] the result object
222
+ #
223
+ def build_delete_result(requested_names, existing_tags, deleted_names, error_map)
224
+ deleted = deleted_names.filter_map { |name| existing_tags[name] }
225
+
226
+ not_deleted = (requested_names - deleted_names).map do |name|
227
+ error_message = error_map[name] || "tag '#{name}' could not be deleted"
228
+ Git::TagDeleteFailure.new(name: name, error_message: error_message)
229
+ end
230
+
231
+ Git::TagDeleteResult.new(deleted: deleted, not_deleted: not_deleted)
232
+ end
233
+
234
+ # Generate error message for unexpected tag record format
235
+ #
236
+ # @param records [Array<String>] all output records
237
+ # @param record [String] the problematic record
238
+ # @param index [Integer] the record index
239
+ # @return [String] formatted error message
240
+ #
241
+ def unexpected_tag_record_error(records, record, index)
242
+ format_str = FORMAT_STRING.gsub(FIELD_DELIMITER, '<FS>').gsub(RECORD_DELIMITER, '<RS>')
243
+ <<~ERROR
244
+ Unexpected record in output from `git tag --list --format=#{format_str}`, at index #{index}
245
+
246
+ Expected #{FIELD_COUNT} fields separated by '\\x1f' (unit separator), got #{record.split(FIELD_DELIMITER, -1).length}
247
+
248
+ Full output:
249
+ #{records.join("\n ")}
250
+
251
+ Record at index #{index}:
252
+ "#{record}"
253
+ ERROR
254
+ end
255
+ end
256
+ end
257
+ end