git 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/errors'
4
+ require 'git/status_file_info'
5
+
6
+ module Git
7
+ module Parsers
8
+ # Parser for `git status --porcelain=v2 -z` output
9
+ #
10
+ # Builds one {Git::StatusFileInfo} per entry. With `-z` every entry is
11
+ # NUL-terminated and paths are emitted verbatim (no quoting), so a path may
12
+ # contain spaces but never a NUL. The original path of a rename or copy
13
+ # entry is the NUL-terminated token that follows the entry.
14
+ #
15
+ # Every entry type of the porcelain v2 format is handled: `1` (ordinary),
16
+ # `2` (rename or copy), `u` (unmerged), `?` (untracked), and `!` (ignored).
17
+ # `#` header lines, emitted with `--branch` or `--show-stash`, are skipped.
18
+ #
19
+ # {Git::StatusFileInfo} lives at the top-level `Git::` namespace rather than
20
+ # within `Git::Parsers::` because it is public API returned to callers,
21
+ # while this parser is infrastructure.
22
+ #
23
+ # @example Parse the output of `git status --porcelain=v2 -z`
24
+ # Git::Parsers::Status.parse("? new.txt\0")
25
+ # #=> [#<data Git::StatusFileInfo path="new.txt", index_status="?", ...>]
26
+ #
27
+ # @see https://git-scm.com/docs/git-status#_porcelain_format_version_2
28
+ #
29
+ # @api private
30
+ #
31
+ module Status
32
+ # Separator between entries (and between a rename entry and its original path)
33
+ ENTRY_SEPARATOR = "\0"
34
+
35
+ # Separator between the fields of one entry
36
+ FIELD_SEPARATOR = / /
37
+
38
+ # First character of an ordinary (changed, added, or deleted) entry
39
+ ORDINARY_ENTRY = '1'
40
+
41
+ # First character of a rename or copy entry
42
+ RENAMED_ENTRY = '2'
43
+
44
+ # First character of an unmerged entry
45
+ UNMERGED_ENTRY = 'u'
46
+
47
+ # First character of an untracked entry
48
+ UNTRACKED_ENTRY = '?'
49
+
50
+ # First character of an ignored entry
51
+ IGNORED_ENTRY = '!'
52
+
53
+ # First character of a header line
54
+ HEADER_LINE = '#'
55
+
56
+ # Field count of an ordinary entry: `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>`
57
+ ORDINARY_FIELD_COUNT = 9
58
+
59
+ # Field count of a rename or copy entry, which adds `<X><score>` before the path
60
+ RENAMED_FIELD_COUNT = 10
61
+
62
+ # Field count of an unmerged entry: `u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>`
63
+ UNMERGED_FIELD_COUNT = 11
64
+
65
+ # Field count of an untracked or ignored entry: `? <path>` or `! <path>`
66
+ PATH_ONLY_FIELD_COUNT = 2
67
+
68
+ # Every {Git::StatusFileInfo} member set to `nil`, for entries that lack a field
69
+ #
70
+ # @return [Hash{Symbol => nil}]
71
+ #
72
+ EMPTY_MEMBERS = Git::StatusFileInfo.members.to_h { |member| [member, nil] }.freeze
73
+
74
+ module_function
75
+
76
+ # Parse `git status --porcelain=v2 -z` output into StatusFileInfo objects
77
+ #
78
+ # @example Parse two entries
79
+ # Git::Parsers::Status.parse(
80
+ # "1 .M N... 100644 100644 100644 #{sha} #{sha} lib/foo.rb\0? new.txt\0"
81
+ # ).map(&:path) #=> ["lib/foo.rb", "new.txt"]
82
+ #
83
+ # @param stdout [String] the NUL-separated output of `git status --porcelain=v2 -z`
84
+ #
85
+ # @return [Array<Git::StatusFileInfo>] one entry per reported path, in git's order
86
+ #
87
+ # @raise [Git::UnexpectedResultError] if an entry does not match the porcelain v2 format
88
+ #
89
+ def parse(stdout)
90
+ tokens = stdout.split(ENTRY_SEPARATOR)
91
+ files = []
92
+ until tokens.empty?
93
+ entry = tokens.shift
94
+ files << parse_entry(entry, tokens) unless entry.start_with?(HEADER_LINE)
95
+ end
96
+ files
97
+ end
98
+
99
+ # Parse one entry, consuming its original path from `tokens` for renames and copies
100
+ #
101
+ # @param entry [String] the entry line without its NUL terminator
102
+ #
103
+ # @param tokens [Array<String>] the entries that follow; a rename or copy
104
+ # entry's original path is shifted off the front
105
+ #
106
+ # @return [Git::StatusFileInfo] the parsed entry
107
+ #
108
+ # @raise [Git::UnexpectedResultError] if the entry type is not recognized
109
+ #
110
+ def parse_entry(entry, tokens)
111
+ case entry[0]
112
+ when ORDINARY_ENTRY then parse_ordinary(entry)
113
+ when RENAMED_ENTRY then parse_renamed(entry, tokens.shift)
114
+ when UNMERGED_ENTRY then parse_unmerged(entry)
115
+ when UNTRACKED_ENTRY, IGNORED_ENTRY then parse_path_only(entry)
116
+ else raise Git::UnexpectedResultError, unexpected_entry_error(entry)
117
+ end
118
+ end
119
+
120
+ # Parse an ordinary (`1`) entry
121
+ #
122
+ # @param entry [String] the entry line
123
+ #
124
+ # @return [Git::StatusFileInfo] the parsed entry
125
+ #
126
+ # @raise [Git::UnexpectedResultError] if the entry does not have nine fields
127
+ #
128
+ def parse_ordinary(entry)
129
+ _type, xy, submodule, mode_head, mode_index, mode_worktree, sha_head, sha_index, path =
130
+ split_fields(entry, ORDINARY_FIELD_COUNT)
131
+ build_file_info(xy, path:, submodule:, mode_head:, mode_index:, mode_worktree:, sha_head:, sha_index:)
132
+ end
133
+
134
+ # Parse a rename or copy (`2`) entry
135
+ #
136
+ # @param entry [String] the entry line
137
+ #
138
+ # @param original_path [String, nil] the NUL-terminated token that followed
139
+ # the entry, or `nil` when the output ended
140
+ #
141
+ # @return [Git::StatusFileInfo] the parsed entry
142
+ #
143
+ # @raise [Git::UnexpectedResultError] if the entry does not have ten fields
144
+ # or the original path is missing
145
+ #
146
+ def parse_renamed(entry, original_path)
147
+ _type, xy, submodule, mode_head, mode_index, mode_worktree, sha_head, sha_index, score, path =
148
+ split_fields(entry, RENAMED_FIELD_COUNT)
149
+ raise Git::UnexpectedResultError, unexpected_entry_error(entry) if original_path.nil?
150
+
151
+ build_file_info(
152
+ xy,
153
+ path:, submodule:, mode_head:, mode_index:, mode_worktree:, sha_head:, sha_index:,
154
+ original_path:, rename_score: score[1..].to_i
155
+ )
156
+ end
157
+
158
+ # Parse an unmerged (`u`) entry
159
+ #
160
+ # @param entry [String] the entry line
161
+ #
162
+ # @return [Git::StatusFileInfo] the parsed entry with its stage data in
163
+ # `unmerged_stages`
164
+ #
165
+ # @raise [Git::UnexpectedResultError] if the entry does not have eleven fields
166
+ #
167
+ def parse_unmerged(entry)
168
+ _type, xy, submodule, mode1, mode2, mode3, mode_worktree, sha1, sha2, sha3, path =
169
+ split_fields(entry, UNMERGED_FIELD_COUNT)
170
+ stages = unmerged_stages([mode1, mode2, mode3], [sha1, sha2, sha3])
171
+ build_file_info(xy, path:, submodule:, mode_worktree:, unmerged_stages: stages)
172
+ end
173
+
174
+ # Parse an untracked (`?`) or ignored (`!`) entry
175
+ #
176
+ # The entry's single status character is used for both status positions,
177
+ # matching the `??` and `!!` codes of the short format.
178
+ #
179
+ # @param entry [String] the entry line
180
+ #
181
+ # @return [Git::StatusFileInfo] the parsed entry with `nil` metadata
182
+ #
183
+ # @raise [Git::UnexpectedResultError] if the entry does not have two fields
184
+ #
185
+ def parse_path_only(entry)
186
+ type, path = split_fields(entry, PATH_ONLY_FIELD_COUNT)
187
+ build_file_info(type * 2, path: path)
188
+ end
189
+
190
+ # Split an entry into exactly `count` fields, the last of which is the path
191
+ #
192
+ # The path may contain spaces, so the split is limited to `count` fields.
193
+ #
194
+ # @param entry [String] the entry line
195
+ #
196
+ # @param count [Integer] the number of fields the entry type has
197
+ #
198
+ # @return [Array<String>] the fields
199
+ #
200
+ # @raise [Git::UnexpectedResultError] if the entry has fewer fields
201
+ #
202
+ def split_fields(entry, count)
203
+ fields = entry.split(FIELD_SEPARATOR, count)
204
+ return fields if fields.length == count
205
+
206
+ raise Git::UnexpectedResultError, unexpected_entry_error(entry)
207
+ end
208
+
209
+ # Build the frozen stage hash of an unmerged entry
210
+ #
211
+ # @param modes [Array<String>] the stage 1, 2, and 3 modes
212
+ #
213
+ # @param shas [Array<String>] the stage 1, 2, and 3 object names
214
+ #
215
+ # @return [Hash{Integer => Hash{Symbol => String}}] frozen `\\{ mode:, sha: }`
216
+ # hashes keyed by stage number
217
+ #
218
+ def unmerged_stages(modes, shas)
219
+ modes.zip(shas).each_with_index.to_h do |(mode, sha), index|
220
+ [index + 1, { mode: mode, sha: sha }.freeze]
221
+ end.freeze
222
+ end
223
+
224
+ # Build a {Git::StatusFileInfo}, defaulting every member not given to `nil`
225
+ #
226
+ # @param statuses [String] the two status characters, `X` then `Y`
227
+ #
228
+ # @param members [Hash{Symbol => Object}] the members that the entry provides
229
+ #
230
+ # @option members [String] :path the repository-relative path
231
+ #
232
+ # @return [Git::StatusFileInfo] the value object
233
+ #
234
+ def build_file_info(statuses, **members)
235
+ Git::StatusFileInfo.new(
236
+ **EMPTY_MEMBERS, index_status: statuses[0], worktree_status: statuses[1], **members
237
+ )
238
+ end
239
+
240
+ # Generate the error message for an entry that does not match the format
241
+ #
242
+ # @param entry [String] the offending entry line
243
+ #
244
+ # @return [String] the message
245
+ #
246
+ def unexpected_entry_error(entry)
247
+ "Unexpected entry in output from `git status --porcelain=v2 -z`: #{entry.inspect}"
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/worktree_info'
4
+
5
+ module Git
6
+ module Parsers
7
+ # Parser for git worktree command output
8
+ #
9
+ # Handles parsing of `git worktree list --porcelain` output into structured
10
+ # data objects.
11
+ #
12
+ # @note Known limitation: git C-quotes a lock or prune reason that contains
13
+ # unusual characters such as a newline or a non-ASCII byte (see the
14
+ # `--porcelain` description in the git-worktree documentation). The reason
15
+ # is returned as git prints it, quotes and escapes included; it is not
16
+ # unquoted.
17
+ #
18
+ # ## Design Note: Namespace Organization
19
+ #
20
+ # This parser creates and returns {Git::WorktreeInfo} objects, which live at
21
+ # the top-level `Git::` namespace rather than within `Git::Parsers::`. This
22
+ # is intentional:
23
+ #
24
+ # - **Parsers are infrastructure** - marked `@api private`, users shouldn't
25
+ # interact with them directly
26
+ # - **Info classes are public API** - returned by commands and used throughout
27
+ # the codebase
28
+ # - **Info classes are domain entities** - represent core git concepts
29
+ # (worktrees as data)
30
+ #
31
+ # Keeping Info classes at `Git::` improves discoverability and correctly
32
+ # reflects their role as public types rather than parser internals.
33
+ #
34
+ # @api private
35
+ #
36
+ module Worktree
37
+ # Pattern splitting a porcelain line into its key and optional value
38
+ #
39
+ # The key is everything before the first space and the value is everything
40
+ # after it, so a path or reason that contains spaces is kept intact. The
41
+ # pattern matches every line; a line with no space has a nil value.
42
+ LINE_PATTERN = /\A(?<key>[^ ]*)(?: (?<value>.*))?\z/
43
+
44
+ # Attribute values for a worktree with no flags set
45
+ #
46
+ # @return [Hash{Symbol => Object}]
47
+ DEFAULT_ATTRS = {
48
+ head: nil, branch: nil, bare: false, detached: false,
49
+ locked: false, lock_reason: nil, prunable: false, prune_reason: nil
50
+ }.freeze
51
+
52
+ module_function
53
+
54
+ # Parse git worktree list --porcelain output into WorktreeInfo objects
55
+ #
56
+ # Records are separated by a blank line. Each record starts with a
57
+ # `worktree <path>` line followed by any of `HEAD <sha>`, `branch <ref>`,
58
+ # `bare`, `detached`, `locked [<reason>]`, and `prunable <reason>`.
59
+ #
60
+ # @example
61
+ # Git::Parsers::Worktree.parse_list(
62
+ # "worktree /tmp/wt/main\nHEAD f3e2c1f...\nbranch refs/heads/main\n"
63
+ # )
64
+ # # => [#<data Git::WorktreeInfo path="/tmp/wt/main", ...>]
65
+ #
66
+ # @param stdout [String] output from `git worktree list --porcelain`
67
+ #
68
+ # @return [Array<Git::WorktreeInfo>] one entry per worktree, in the order
69
+ # git listed them (the main worktree first)
70
+ #
71
+ # @raise [Git::UnexpectedResultError] if a record does not start with a
72
+ # `worktree` line or contains an unrecognized key
73
+ #
74
+ def parse_list(stdout)
75
+ records(stdout).map { |lines| parse_record(lines, stdout) }
76
+ end
77
+
78
+ # Split the output into records, each an array of chomped non-blank lines
79
+ #
80
+ # Blank lines separate records. `chunk` drops every run of lines whose
81
+ # block value is `:_separator`, so only the non-blank runs are returned.
82
+ #
83
+ # @param stdout [String] output from `git worktree list --porcelain`
84
+ #
85
+ # @return [Array<Array<String>>] the lines of each record
86
+ #
87
+ def records(stdout)
88
+ stdout.each_line(chomp: true).chunk { |line| line.empty? ? :_separator : true }.map { |_, lines| lines }
89
+ end
90
+
91
+ # Parse one record into a WorktreeInfo
92
+ #
93
+ # @param lines [Array<String>] the lines of the record
94
+ #
95
+ # @param stdout [String] the full output (for error messages)
96
+ #
97
+ # @return [Git::WorktreeInfo] the parsed entry
98
+ #
99
+ # @raise [Git::UnexpectedResultError] if the record does not start with a
100
+ # `worktree` line or contains an unrecognized key
101
+ #
102
+ def parse_record(lines, stdout)
103
+ key, path = split_line(lines.first)
104
+ unless key == 'worktree' && path
105
+ raise Git::UnexpectedResultError,
106
+ unexpected_line_error(stdout, lines.first, 'expected a record to start with "worktree <path>"')
107
+ end
108
+
109
+ Git::WorktreeInfo.new(path: path, **DEFAULT_ATTRS, **record_attrs(lines.drop(1), stdout))
110
+ end
111
+
112
+ # Collect the attributes set by the lines that follow the `worktree` line
113
+ #
114
+ # @param lines [Array<String>] the record's lines after the first
115
+ #
116
+ # @param stdout [String] the full output (for error messages)
117
+ #
118
+ # @return [Hash{Symbol => Object}] the attributes to override in DEFAULT_ATTRS
119
+ #
120
+ # @raise [Git::UnexpectedResultError] if a line has an unrecognized key
121
+ #
122
+ def record_attrs(lines, stdout)
123
+ lines.each_with_object({}) { |line, attrs| attrs.merge!(line_attrs(line, stdout)) }
124
+ end
125
+
126
+ # Map one porcelain line to the WorktreeInfo attributes it sets
127
+ #
128
+ # @param line [String] a chomped line of porcelain output
129
+ #
130
+ # @param stdout [String] the full output (for error messages)
131
+ #
132
+ # @return [Hash{Symbol => Object}] the attributes set by the line
133
+ #
134
+ # @raise [Git::UnexpectedResultError] if the line has an unrecognized key
135
+ #
136
+ def line_attrs(line, stdout)
137
+ key, value = split_line(line)
138
+
139
+ case key
140
+ when 'HEAD' then { head: value }
141
+ when 'branch' then { branch: value }
142
+ when 'bare' then { bare: true }
143
+ when 'detached' then { detached: true }
144
+ when 'locked' then { locked: true, lock_reason: value }
145
+ when 'prunable' then { prunable: true, prune_reason: value }
146
+ else raise Git::UnexpectedResultError, unexpected_line_error(stdout, line, 'unrecognized key')
147
+ end
148
+ end
149
+
150
+ # Split a porcelain line into its key and optional value
151
+ #
152
+ # @param line [String] a chomped line of porcelain output
153
+ #
154
+ # @return [Array(String, String), Array(String, nil)] the key and the
155
+ # value, or nil when the line has no value
156
+ #
157
+ def split_line(line)
158
+ match = LINE_PATTERN.match(line)
159
+ [match[:key], match[:value]]
160
+ end
161
+
162
+ # Generate the error message for a line the parser cannot handle
163
+ #
164
+ # @param stdout [String] the full output
165
+ #
166
+ # @param line [String] the problematic line
167
+ #
168
+ # @param reason [String] why the line is unexpected
169
+ #
170
+ # @return [String] the formatted error message
171
+ #
172
+ def unexpected_line_error(stdout, line, reason)
173
+ <<~ERROR
174
+ Unexpected line in output from `git worktree list --porcelain`: #{reason}
175
+
176
+ Line:
177
+ "#{line}"
178
+
179
+ Full output:
180
+ #{stdout.gsub("\n", "\n ")}
181
+ ERROR
182
+ end
183
+ end
184
+ end
185
+ end