git 1.19.1 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +5 -1
- data/AI_POLICY.md +24 -0
- data/CHANGELOG.md +689 -0
- data/CODE_OF_CONDUCT.md +25 -0
- data/CONTRIBUTING.md +1175 -97
- data/GOVERNANCE.md +106 -0
- data/LICENSE +1 -1
- data/MAINTAINERS.md +17 -4
- data/README.md +476 -320
- data/UPGRADING.md +1138 -0
- data/git.gemspec +124 -36
- data/lib/git/author.rb +39 -7
- data/lib/git/author_info.rb +66 -0
- data/lib/git/branch.rb +615 -65
- data/lib/git/branch_delete_failure.rb +34 -0
- data/lib/git/branch_delete_result.rb +66 -0
- data/lib/git/branch_info.rb +237 -0
- data/lib/git/branches.rb +167 -44
- data/lib/git/command_line/base.rb +247 -0
- data/lib/git/command_line/capturing.rb +308 -0
- data/lib/git/command_line/result.rb +88 -0
- data/lib/git/command_line/streaming.rb +236 -0
- data/lib/git/command_line.rb +52 -0
- data/lib/git/commands/add.rb +139 -0
- data/lib/git/commands/am/abort.rb +43 -0
- data/lib/git/commands/am/apply.rb +263 -0
- data/lib/git/commands/am/continue.rb +43 -0
- data/lib/git/commands/am/quit.rb +43 -0
- data/lib/git/commands/am/retry.rb +49 -0
- data/lib/git/commands/am/show_current_patch.rb +64 -0
- data/lib/git/commands/am/skip.rb +42 -0
- data/lib/git/commands/am.rb +33 -0
- data/lib/git/commands/apply.rb +242 -0
- data/lib/git/commands/archive/list_formats.rb +46 -0
- data/lib/git/commands/archive.rb +145 -0
- data/lib/git/commands/arguments.rb +4521 -0
- data/lib/git/commands/base.rb +502 -0
- data/lib/git/commands/branch/copy.rb +102 -0
- data/lib/git/commands/branch/create.rb +177 -0
- data/lib/git/commands/branch/delete.rb +88 -0
- data/lib/git/commands/branch/list.rb +178 -0
- data/lib/git/commands/branch/move.rb +102 -0
- data/lib/git/commands/branch/set_upstream.rb +86 -0
- data/lib/git/commands/branch/show_current.rb +49 -0
- data/lib/git/commands/branch/unset_upstream.rb +53 -0
- data/lib/git/commands/branch.rb +34 -0
- data/lib/git/commands/cat_file/batch.rb +385 -0
- data/lib/git/commands/cat_file/filtered.rb +105 -0
- data/lib/git/commands/cat_file/raw.rb +271 -0
- data/lib/git/commands/cat_file.rb +49 -0
- data/lib/git/commands/checkout/branch.rb +153 -0
- data/lib/git/commands/checkout/files.rb +116 -0
- data/lib/git/commands/checkout.rb +38 -0
- data/lib/git/commands/checkout_index.rb +106 -0
- data/lib/git/commands/clean.rb +102 -0
- data/lib/git/commands/clone.rb +241 -0
- data/lib/git/commands/commit.rb +273 -0
- data/lib/git/commands/commit_tree.rb +101 -0
- data/lib/git/commands/config_option_syntax/add.rb +86 -0
- data/lib/git/commands/config_option_syntax/get.rb +121 -0
- data/lib/git/commands/config_option_syntax/get_all.rb +118 -0
- data/lib/git/commands/config_option_syntax/get_color.rb +95 -0
- data/lib/git/commands/config_option_syntax/get_color_bool.rb +96 -0
- data/lib/git/commands/config_option_syntax/get_regexp.rb +119 -0
- data/lib/git/commands/config_option_syntax/get_urlmatch.rb +111 -0
- data/lib/git/commands/config_option_syntax/list.rb +111 -0
- data/lib/git/commands/config_option_syntax/remove_section.rb +79 -0
- data/lib/git/commands/config_option_syntax/rename_section.rb +83 -0
- data/lib/git/commands/config_option_syntax/replace_all.rb +109 -0
- data/lib/git/commands/config_option_syntax/set.rb +119 -0
- data/lib/git/commands/config_option_syntax/unset.rb +92 -0
- data/lib/git/commands/config_option_syntax/unset_all.rb +94 -0
- data/lib/git/commands/config_option_syntax.rb +56 -0
- data/lib/git/commands/describe.rb +156 -0
- data/lib/git/commands/diff.rb +657 -0
- data/lib/git/commands/diff_files.rb +519 -0
- data/lib/git/commands/diff_index.rb +499 -0
- data/lib/git/commands/fetch.rb +354 -0
- data/lib/git/commands/fsck.rb +138 -0
- data/lib/git/commands/gc.rb +134 -0
- data/lib/git/commands/grep.rb +339 -0
- data/lib/git/commands/init.rb +101 -0
- data/lib/git/commands/log.rb +634 -0
- data/lib/git/commands/ls_files.rb +195 -0
- data/lib/git/commands/ls_remote.rb +161 -0
- data/lib/git/commands/ls_tree.rb +135 -0
- data/lib/git/commands/maintenance/register.rb +77 -0
- data/lib/git/commands/maintenance/run.rb +109 -0
- data/lib/git/commands/maintenance/start.rb +71 -0
- data/lib/git/commands/maintenance/stop.rb +60 -0
- data/lib/git/commands/maintenance/unregister.rb +84 -0
- data/lib/git/commands/maintenance.rb +31 -0
- data/lib/git/commands/merge/abort.rb +44 -0
- data/lib/git/commands/merge/continue.rb +44 -0
- data/lib/git/commands/merge/quit.rb +46 -0
- data/lib/git/commands/merge/start.rb +250 -0
- data/lib/git/commands/merge.rb +28 -0
- data/lib/git/commands/merge_base.rb +91 -0
- data/lib/git/commands/mv.rb +82 -0
- data/lib/git/commands/name_rev.rb +119 -0
- data/lib/git/commands/pull.rb +382 -0
- data/lib/git/commands/push.rb +251 -0
- data/lib/git/commands/read_tree.rb +154 -0
- data/lib/git/commands/remote/add.rb +96 -0
- data/lib/git/commands/remote/get_url.rb +68 -0
- data/lib/git/commands/remote/list.rb +56 -0
- data/lib/git/commands/remote/prune.rb +63 -0
- data/lib/git/commands/remote/remove.rb +52 -0
- data/lib/git/commands/remote/rename.rb +76 -0
- data/lib/git/commands/remote/set_branches.rb +70 -0
- data/lib/git/commands/remote/set_head.rb +89 -0
- data/lib/git/commands/remote/set_url.rb +78 -0
- data/lib/git/commands/remote/set_url_add.rb +70 -0
- data/lib/git/commands/remote/set_url_delete.rb +71 -0
- data/lib/git/commands/remote/show.rb +77 -0
- data/lib/git/commands/remote/update.rb +79 -0
- data/lib/git/commands/remote.rb +42 -0
- data/lib/git/commands/repack.rb +281 -0
- data/lib/git/commands/reset.rb +154 -0
- data/lib/git/commands/rev_parse.rb +304 -0
- data/lib/git/commands/revert/abort.rb +45 -0
- data/lib/git/commands/revert/continue.rb +62 -0
- data/lib/git/commands/revert/quit.rb +47 -0
- data/lib/git/commands/revert/skip.rb +44 -0
- data/lib/git/commands/revert/start.rb +158 -0
- data/lib/git/commands/revert.rb +29 -0
- data/lib/git/commands/rm.rb +113 -0
- data/lib/git/commands/show.rb +632 -0
- data/lib/git/commands/show_ref/exclude_existing.rb +119 -0
- data/lib/git/commands/show_ref/exists.rb +80 -0
- data/lib/git/commands/show_ref/list.rb +149 -0
- data/lib/git/commands/show_ref/verify.rb +122 -0
- data/lib/git/commands/show_ref.rb +42 -0
- data/lib/git/commands/stash/apply.rb +81 -0
- data/lib/git/commands/stash/branch.rb +67 -0
- data/lib/git/commands/stash/clear.rb +43 -0
- data/lib/git/commands/stash/create.rb +60 -0
- data/lib/git/commands/stash/drop.rb +73 -0
- data/lib/git/commands/stash/list.rb +43 -0
- data/lib/git/commands/stash/pop.rb +87 -0
- data/lib/git/commands/stash/push.rb +112 -0
- data/lib/git/commands/stash/show.rb +158 -0
- data/lib/git/commands/stash/store.rb +72 -0
- data/lib/git/commands/stash.rb +38 -0
- data/lib/git/commands/status.rb +174 -0
- data/lib/git/commands/symbolic_ref/delete.rb +72 -0
- data/lib/git/commands/symbolic_ref/read.rb +99 -0
- data/lib/git/commands/symbolic_ref/update.rb +79 -0
- data/lib/git/commands/symbolic_ref.rb +38 -0
- data/lib/git/commands/tag/create.rb +142 -0
- data/lib/git/commands/tag/delete.rb +57 -0
- data/lib/git/commands/tag/list.rb +146 -0
- data/lib/git/commands/tag/verify.rb +71 -0
- data/lib/git/commands/tag.rb +26 -0
- data/lib/git/commands/update_ref/batch.rb +145 -0
- data/lib/git/commands/update_ref/delete.rb +90 -0
- data/lib/git/commands/update_ref/update.rb +103 -0
- data/lib/git/commands/update_ref.rb +42 -0
- data/lib/git/commands/version.rb +60 -0
- data/lib/git/commands/worktree/add.rb +139 -0
- data/lib/git/commands/worktree/list.rb +64 -0
- data/lib/git/commands/worktree/lock.rb +58 -0
- data/lib/git/commands/worktree/management_base.rb +51 -0
- data/lib/git/commands/worktree/move.rb +66 -0
- data/lib/git/commands/worktree/prune.rb +67 -0
- data/lib/git/commands/worktree/remove.rb +63 -0
- data/lib/git/commands/worktree/repair.rb +76 -0
- data/lib/git/commands/worktree/unlock.rb +47 -0
- data/lib/git/commands/worktree.rb +43 -0
- data/lib/git/commands/write_tree.rb +68 -0
- data/lib/git/commands.rb +88 -0
- data/lib/git/config.rb +72 -5
- data/lib/git/config_entry_info.rb +106 -0
- data/lib/git/configuring.rb +795 -0
- data/lib/git/detached_head_info.rb +57 -0
- data/lib/git/diff.rb +437 -86
- data/lib/git/diff_file_numstat_info.rb +31 -0
- data/lib/git/diff_file_patch_info.rb +136 -0
- data/lib/git/diff_file_raw_info.rb +129 -0
- data/lib/git/diff_info.rb +162 -0
- data/lib/git/diff_path_status.rb +107 -0
- data/lib/git/diff_result.rb +34 -0
- data/lib/git/diff_stats.rb +111 -0
- data/lib/git/dirstat_info.rb +102 -0
- data/lib/git/encoding_utils.rb +32 -1
- data/lib/git/errors.rb +285 -0
- data/lib/git/escaped_path.rb +57 -5
- data/lib/git/execution_context/global.rb +31 -0
- data/lib/git/execution_context/repository.rb +151 -0
- data/lib/git/execution_context.rb +559 -0
- data/lib/git/factories.rb +813 -0
- data/lib/git/file_ref.rb +77 -0
- data/lib/git/fsck_object.rb +56 -0
- data/lib/git/fsck_result.rb +132 -0
- data/lib/git/log.rb +306 -90
- data/lib/git/object.rb +563 -141
- data/lib/git/parsers/branch.rb +240 -0
- data/lib/git/parsers/cat_file.rb +111 -0
- data/lib/git/parsers/config_entry.rb +110 -0
- data/lib/git/parsers/diff.rb +792 -0
- data/lib/git/parsers/fsck.rb +144 -0
- data/lib/git/parsers/grep.rb +42 -0
- data/lib/git/parsers/ls_remote.rb +79 -0
- data/lib/git/parsers/ls_tree.rb +58 -0
- data/lib/git/parsers/remote.rb +162 -0
- data/lib/git/parsers/stash.rb +292 -0
- data/lib/git/parsers/status.rb +251 -0
- data/lib/git/parsers/tag.rb +341 -0
- data/lib/git/parsers/worktree.rb +185 -0
- data/lib/git/path_resolver.rb +206 -0
- data/lib/git/remote.rb +165 -12
- data/lib/git/remote_info.rb +203 -0
- data/lib/git/repository/branching.rb +964 -0
- data/lib/git/repository/committing.rb +246 -0
- data/lib/git/repository/context_helpers.rb +293 -0
- data/lib/git/repository/diffing.rb +785 -0
- data/lib/git/repository/inspecting.rb +252 -0
- data/lib/git/repository/logging.rb +410 -0
- data/lib/git/repository/maintenance.rb +65 -0
- data/lib/git/repository/merging.rb +451 -0
- data/lib/git/repository/object_operations.rb +1551 -0
- data/lib/git/repository/remote_operations.rb +984 -0
- data/lib/git/repository/shared_private.rb +120 -0
- data/lib/git/repository/staging.rb +587 -0
- data/lib/git/repository/stashing.rb +623 -0
- data/lib/git/repository/status_operations.rb +249 -0
- data/lib/git/repository/worktree_operations.rb +339 -0
- data/lib/git/repository.rb +484 -2
- data/lib/git/stash.rb +109 -12
- data/lib/git/stash_info.rb +102 -0
- data/lib/git/stashes.rb +169 -26
- data/lib/git/status.rb +308 -122
- data/lib/git/status_file_info.rb +258 -0
- data/lib/git/status_info.rb +189 -0
- data/lib/git/tag_delete_failure.rb +34 -0
- data/lib/git/tag_delete_result.rb +66 -0
- data/lib/git/tag_info.rb +99 -0
- data/lib/git/url.rb +15 -8
- data/lib/git/version.rb +113 -2
- data/lib/git/version_constraint.rb +85 -0
- data/lib/git/worktree.rb +150 -8
- data/lib/git/worktree_info.rb +128 -0
- data/lib/git/worktrees.rb +118 -13
- data/lib/git.rb +632 -234
- metadata +369 -54
- data/.github/stale.yml +0 -25
- data/.github/workflows/continuous_integration.yml +0 -49
- data/.gitignore +0 -10
- data/Dockerfile.changelog-rs +0 -12
- data/Gemfile +0 -5
- data/ISSUE_TEMPLATE.md +0 -15
- data/PULL_REQUEST_TEMPLATE.md +0 -9
- data/RELEASING.md +0 -70
- data/Rakefile +0 -60
- data/lib/git/base/factory.rb +0 -99
- data/lib/git/base.rb +0 -711
- data/lib/git/command_line_result.rb +0 -86
- data/lib/git/failed_error.rb +0 -53
- data/lib/git/git_execute_error.rb +0 -7
- data/lib/git/index.rb +0 -5
- data/lib/git/lib.rb +0 -1328
- data/lib/git/path.rb +0 -31
- data/lib/git/signaled_error.rb +0 -50
- data/lib/git/working_directory.rb +0 -4
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
require 'git/author_info'
|
|
6
|
+
require 'git/tag_info'
|
|
7
|
+
require 'git/tag_delete_result'
|
|
8
|
+
require 'git/tag_delete_failure'
|
|
9
|
+
|
|
10
|
+
module Git
|
|
11
|
+
module Parsers
|
|
12
|
+
# Parser for git tag command output
|
|
13
|
+
#
|
|
14
|
+
# Handles parsing of `git tag --list` and `git tag --delete` output
|
|
15
|
+
# into structured data objects.
|
|
16
|
+
#
|
|
17
|
+
# @note Known limitation: If a tag message contains the field delimiter
|
|
18
|
+
# character (\x1f, ASCII unit separator), it will be preserved correctly
|
|
19
|
+
# since the message is the last field. However, messages are rarely crafted
|
|
20
|
+
# with non-printable control characters.
|
|
21
|
+
#
|
|
22
|
+
# ## Design Note: Namespace Organization
|
|
23
|
+
#
|
|
24
|
+
# This parser creates and returns {Git::TagInfo} and {Git::TagDeleteResult}
|
|
25
|
+
# objects, which live at the top-level `Git::` namespace rather than within
|
|
26
|
+
# `Git::Parsers::`. This is intentional:
|
|
27
|
+
#
|
|
28
|
+
# - **Parsers are infrastructure** - marked `@api private`, users shouldn't
|
|
29
|
+
# interact with them directly
|
|
30
|
+
# - **Info/Result classes are public API** - returned by commands and used
|
|
31
|
+
# throughout the codebase
|
|
32
|
+
# - **Info classes are domain entities** - represent core git concepts
|
|
33
|
+
# (tags as data)
|
|
34
|
+
# - **Result classes are operation outcomes** - represent command results,
|
|
35
|
+
# not parsing details
|
|
36
|
+
#
|
|
37
|
+
# Keeping Info/Result classes at `Git::` improves discoverability and correctly
|
|
38
|
+
# reflects their role as public types rather than parser internals.
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
#
|
|
42
|
+
module Tag
|
|
43
|
+
# Delimiter for separating fields in git tag --format output
|
|
44
|
+
# Field separator used in custom format output
|
|
45
|
+
# Using the ASCII unit separator (US, 0x1F / "\x1f"), a non-printable character,
|
|
46
|
+
# minimizes the chance of collisions with tag names or messages and remains
|
|
47
|
+
# safe to pass through Process.spawn and shell argument boundaries.
|
|
48
|
+
FIELD_DELIMITER = "\x1f"
|
|
49
|
+
|
|
50
|
+
# Delimiter for separating records (tags) in output
|
|
51
|
+
# Using the ASCII record separator (RS, 0x1E / "\x1e") to delimit complete tag records.
|
|
52
|
+
# This allows multi-line messages (which contain newlines) to be parsed correctly
|
|
53
|
+
# since we split by record separator first, then by field delimiter.
|
|
54
|
+
RECORD_DELIMITER = "\x1e"
|
|
55
|
+
|
|
56
|
+
# Number of fields expected in the parsed output
|
|
57
|
+
FIELD_COUNT = 8
|
|
58
|
+
|
|
59
|
+
# Format string for git tag --format
|
|
60
|
+
#
|
|
61
|
+
# Fields:
|
|
62
|
+
# - %(refname:short) - tag name
|
|
63
|
+
# - %(objectname) - SHA of the tag object (for annotated) or commit (for lightweight)
|
|
64
|
+
# - %(*objectname) - Dereferenced SHA (commit ID for annotated tags, empty for lightweight)
|
|
65
|
+
# - %(objecttype) - 'tag' for annotated tags, target object type (commit/tree/blob/etc.) for lightweight tags
|
|
66
|
+
# - %(taggername) - tagger name (empty for lightweight tags)
|
|
67
|
+
# - %(taggeremail) - tagger email (empty for lightweight tags)
|
|
68
|
+
# - %(taggerdate:iso8601-strict) - tagger date in strict ISO 8601 format
|
|
69
|
+
# - %(contents) - full tag message (can be multi-line)
|
|
70
|
+
#
|
|
71
|
+
# Each tag record is terminated by the RECORD_DELIMITER to allow multi-line messages.
|
|
72
|
+
FORMAT_STRING = [
|
|
73
|
+
'%(refname:short)',
|
|
74
|
+
'%(objectname)',
|
|
75
|
+
'%(*objectname)',
|
|
76
|
+
'%(objecttype)',
|
|
77
|
+
'%(taggername)',
|
|
78
|
+
'%(taggeremail)',
|
|
79
|
+
'%(taggerdate:iso8601-strict)',
|
|
80
|
+
'%(contents)'
|
|
81
|
+
].join(FIELD_DELIMITER) + RECORD_DELIMITER
|
|
82
|
+
|
|
83
|
+
# Regex to parse successful deletion lines from stdout
|
|
84
|
+
# Matches: Deleted tag 'tagname' (was abc123)
|
|
85
|
+
DELETED_TAG_REGEX = /^Deleted tag '([^']+)'/
|
|
86
|
+
|
|
87
|
+
# Regex to parse error messages from stderr
|
|
88
|
+
# Matches: error: tag 'tagname' not found.
|
|
89
|
+
ERROR_TAG_REGEX = /^error: tag '([^']+)'(.*)$/
|
|
90
|
+
|
|
91
|
+
module_function
|
|
92
|
+
|
|
93
|
+
# Parse git tag --list output into TagInfo objects
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# TagParser.parse_list("v1.0.0\x1f...\x1e\n")
|
|
97
|
+
# # => [#<Git::TagInfo name: "v1.0.0", ...>]
|
|
98
|
+
#
|
|
99
|
+
# @param stdout [String] output from git tag --list --format=...
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Git::TagInfo>] parsed tag information
|
|
102
|
+
#
|
|
103
|
+
# @raise [Git::UnexpectedResultError] if any record has unexpected format
|
|
104
|
+
#
|
|
105
|
+
def parse_list(stdout)
|
|
106
|
+
# Split by record separator
|
|
107
|
+
# Each record may have a leading newline from the previous record's %(contents) output
|
|
108
|
+
# Use lstrip to remove leading whitespace (which includes the newline) from each record
|
|
109
|
+
records = stdout.split(RECORD_DELIMITER).map(&:lstrip).reject(&:empty?)
|
|
110
|
+
records.map.with_index { |record, index| parse_tag_record(record, index, records) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Parse a single formatted tag record
|
|
114
|
+
#
|
|
115
|
+
# The record format is:
|
|
116
|
+
# name<FS>sha<FS>deref<FS>objecttype<FS>tagger_name<FS>tagger_email<FS>tagger_date<FS>message
|
|
117
|
+
# where <FS> is the unit separator character ("\x1f").
|
|
118
|
+
#
|
|
119
|
+
# For lightweight tags, Git emits empty strings for the tagger fields and message;
|
|
120
|
+
# these are converted to nil by {#parse_tagger} and {#parse_message}.
|
|
121
|
+
#
|
|
122
|
+
# @param record [String] a single tag record from git tag --format output
|
|
123
|
+
#
|
|
124
|
+
# @param index [Integer] record index for error reporting
|
|
125
|
+
#
|
|
126
|
+
# @param all_records [Array<String>] all output records for error messages
|
|
127
|
+
#
|
|
128
|
+
# @return [Git::TagInfo] tag info with all fields populated
|
|
129
|
+
#
|
|
130
|
+
# @raise [Git::UnexpectedResultError] if record format is unexpected
|
|
131
|
+
#
|
|
132
|
+
def parse_tag_record(record, index, all_records)
|
|
133
|
+
parts = record.split(FIELD_DELIMITER, FIELD_COUNT)
|
|
134
|
+
|
|
135
|
+
unless parts.length == FIELD_COUNT
|
|
136
|
+
raise Git::UnexpectedResultError, unexpected_tag_record_error(all_records, record, index)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
build_tag_info(parts)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Build a TagInfo object from parsed parts
|
|
143
|
+
#
|
|
144
|
+
# @param parts [Array<String>] the parsed format fields
|
|
145
|
+
#
|
|
146
|
+
# @return [Git::TagInfo]
|
|
147
|
+
#
|
|
148
|
+
# @note For annotated tags:
|
|
149
|
+
# - oid = %(objectname) (the tag object's ID)
|
|
150
|
+
# - target_oid = %(*objectname) (the dereferenced commit ID)
|
|
151
|
+
#
|
|
152
|
+
# @note For lightweight tags:
|
|
153
|
+
# - oid = nil (lightweight tags are not objects)
|
|
154
|
+
# - target_oid = %(objectname) (the commit ID)
|
|
155
|
+
#
|
|
156
|
+
def build_tag_info(parts)
|
|
157
|
+
oid, target_oid = resolve_oids(parts[3], parts[1], parts[2])
|
|
158
|
+
build_tag_info_object(parts, oid, target_oid)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Resolves canonical and target object OIDs from git tag format fields
|
|
162
|
+
#
|
|
163
|
+
# @param objecttype [String] the object type from git output
|
|
164
|
+
#
|
|
165
|
+
# @param objectname [String] the object OID from %(objectname)
|
|
166
|
+
#
|
|
167
|
+
# @param dereferenced [String] the object OID from %(*objectname)
|
|
168
|
+
#
|
|
169
|
+
# @return [Array((String, nil), String)] the two-element tuple
|
|
170
|
+
# `[oid, target_oid]`
|
|
171
|
+
#
|
|
172
|
+
def resolve_oids(objecttype, objectname, dereferenced)
|
|
173
|
+
objecttype == 'tag' ? [objectname, dereferenced] : [nil, objectname]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Builds a TagInfo object from normalized parser values
|
|
177
|
+
#
|
|
178
|
+
# @param parts [Array<String>] the parsed format fields
|
|
179
|
+
#
|
|
180
|
+
# @param oid [String, nil] the tag object's OID or nil for lightweight tags
|
|
181
|
+
#
|
|
182
|
+
# @param target_oid [String] the target object OID
|
|
183
|
+
#
|
|
184
|
+
# @return [Git::TagInfo] the tag info with all fields populated
|
|
185
|
+
#
|
|
186
|
+
def build_tag_info_object(parts, oid, target_oid)
|
|
187
|
+
Git::TagInfo.new(
|
|
188
|
+
name: parts[0], oid: oid, target_oid: target_oid, objecttype: parts[3],
|
|
189
|
+
tagger: parse_tagger(parts[4], parts[5], parts[6]), message: parse_message(parts[3], parts[7])
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Build the tagger identity from the tagger name, email, and date fields
|
|
194
|
+
#
|
|
195
|
+
# Git emits empty strings for all three fields when there is no tag object
|
|
196
|
+
# (lightweight tags) or the tag object has no tagger header, in which case
|
|
197
|
+
# the tagger is nil. Otherwise the angle brackets git wraps around
|
|
198
|
+
# `%(taggeremail)` are stripped and the strict ISO 8601
|
|
199
|
+
# `%(taggerdate:iso8601-strict)` value is parsed into a `Time` that
|
|
200
|
+
# preserves the UTC offset. A partially populated identity (for example an
|
|
201
|
+
# empty name with an email and date) is kept as emitted rather than dropped,
|
|
202
|
+
# and an empty date becomes `nil`.
|
|
203
|
+
#
|
|
204
|
+
# @example An annotated tag's tagger
|
|
205
|
+
# parse_tagger('John Doe', '<john@example.com>', '2024-01-15T10:30:00-08:00')
|
|
206
|
+
# #=> #<data Git::AuthorInfo name="John Doe", email="john@example.com", ...>
|
|
207
|
+
#
|
|
208
|
+
# @example A lightweight tag has no tagger
|
|
209
|
+
# parse_tagger('', '', '') #=> nil
|
|
210
|
+
#
|
|
211
|
+
# @param name [String] the `%(taggername)` field
|
|
212
|
+
#
|
|
213
|
+
# @param email [String] the `%(taggeremail)` field, including angle brackets
|
|
214
|
+
#
|
|
215
|
+
# @param date [String] the `%(taggerdate:iso8601-strict)` field
|
|
216
|
+
#
|
|
217
|
+
# @return [Git::AuthorInfo, nil] the tagger, or nil when all three fields are empty
|
|
218
|
+
#
|
|
219
|
+
# @raise [Git::UnexpectedResultError] if a non-empty date is not a valid ISO 8601
|
|
220
|
+
# date
|
|
221
|
+
#
|
|
222
|
+
def parse_tagger(name, email, date)
|
|
223
|
+
return nil if [name, email, date].all?(&:empty?)
|
|
224
|
+
|
|
225
|
+
Git::AuthorInfo.new(
|
|
226
|
+
name: name,
|
|
227
|
+
email: email.delete_prefix('<').delete_suffix('>'),
|
|
228
|
+
date: date.empty? ? nil : parse_date(date)
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Parse a `%(taggerdate:iso8601-strict)` field into a Time
|
|
233
|
+
#
|
|
234
|
+
# @param date [String] the date field in strict ISO 8601 format
|
|
235
|
+
#
|
|
236
|
+
# @return [Time] the parsed time, preserving the UTC offset
|
|
237
|
+
#
|
|
238
|
+
# @raise [Git::UnexpectedResultError] if the field is not a valid ISO 8601 date
|
|
239
|
+
#
|
|
240
|
+
def parse_date(date)
|
|
241
|
+
Time.iso8601(date)
|
|
242
|
+
rescue ArgumentError => e
|
|
243
|
+
raise Git::UnexpectedResultError,
|
|
244
|
+
"Unexpected tagger date #{date.inspect} in output from `git tag --list`: #{e.message}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Parse message field, returning nil for lightweight tags or empty messages
|
|
248
|
+
# Strips trailing newlines that git adds to %(contents) output
|
|
249
|
+
#
|
|
250
|
+
# @param objecttype [String] the object type ('tag' or 'commit')
|
|
251
|
+
#
|
|
252
|
+
# @param message [String] the raw message field
|
|
253
|
+
#
|
|
254
|
+
# @return [String, nil] the message or nil
|
|
255
|
+
#
|
|
256
|
+
def parse_message(objecttype, message)
|
|
257
|
+
stripped = message.chomp
|
|
258
|
+
objecttype == 'tag' && !stripped.empty? ? stripped : nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Parse deleted tag names from stdout
|
|
262
|
+
#
|
|
263
|
+
# @example
|
|
264
|
+
# TagParser.parse_deleted_tags("Deleted tag 'v1.0.0' (was abc123)\n")
|
|
265
|
+
# # => ["v1.0.0"]
|
|
266
|
+
#
|
|
267
|
+
# @param stdout [String] command stdout
|
|
268
|
+
#
|
|
269
|
+
# @return [Array<String>] names of successfully deleted tags
|
|
270
|
+
#
|
|
271
|
+
def parse_deleted_tags(stdout)
|
|
272
|
+
stdout.scan(DELETED_TAG_REGEX).flatten
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Parse error messages from stderr into a map
|
|
276
|
+
#
|
|
277
|
+
# @example
|
|
278
|
+
# TagParser.parse_error_messages("error: tag 'missing' not found.\n")
|
|
279
|
+
# # => {"missing" => "error: tag 'missing' not found."}
|
|
280
|
+
#
|
|
281
|
+
# @param stderr [String] command stderr
|
|
282
|
+
#
|
|
283
|
+
# @return [Hash<String, String>] map of tag name to error message
|
|
284
|
+
#
|
|
285
|
+
def parse_error_messages(stderr)
|
|
286
|
+
stderr.each_line.with_object({}) do |line, hash|
|
|
287
|
+
match = line.match(ERROR_TAG_REGEX)
|
|
288
|
+
hash[match[1]] = line.strip if match
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Build the TagDeleteResult from parsed data
|
|
293
|
+
#
|
|
294
|
+
# @param requested_names [Array<String>] originally requested tag names
|
|
295
|
+
#
|
|
296
|
+
# @param existing_tags [Hash<String, Git::TagInfo>] tags that existed before delete
|
|
297
|
+
#
|
|
298
|
+
# @param deleted_names [Array<String>] names confirmed deleted in stdout
|
|
299
|
+
#
|
|
300
|
+
# @param error_map [Hash<String, String>] map of tag name to error message
|
|
301
|
+
#
|
|
302
|
+
# @return [Git::TagDeleteResult] the result object
|
|
303
|
+
#
|
|
304
|
+
def build_delete_result(requested_names, existing_tags, deleted_names, error_map)
|
|
305
|
+
deleted = deleted_names.filter_map { |name| existing_tags[name] }
|
|
306
|
+
|
|
307
|
+
not_deleted = (requested_names - deleted_names).map do |name|
|
|
308
|
+
error_message = error_map[name] || "tag '#{name}' could not be deleted"
|
|
309
|
+
Git::TagDeleteFailure.new(name: name, error_message: error_message)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
Git::TagDeleteResult.new(deleted: deleted, not_deleted: not_deleted)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Generate error message for unexpected tag record format
|
|
316
|
+
#
|
|
317
|
+
# @param records [Array<String>] all output records
|
|
318
|
+
#
|
|
319
|
+
# @param record [String] the problematic record
|
|
320
|
+
#
|
|
321
|
+
# @param index [Integer] the record index
|
|
322
|
+
#
|
|
323
|
+
# @return [String] formatted error message
|
|
324
|
+
#
|
|
325
|
+
def unexpected_tag_record_error(records, record, index)
|
|
326
|
+
format_str = FORMAT_STRING.gsub(FIELD_DELIMITER, '<FS>').gsub(RECORD_DELIMITER, '<RS>')
|
|
327
|
+
<<~ERROR
|
|
328
|
+
Unexpected record in output from `git tag --list --format=#{format_str}`, at index #{index}
|
|
329
|
+
|
|
330
|
+
Expected #{FIELD_COUNT} fields separated by '\\x1f' (unit separator), got #{record.split(FIELD_DELIMITER, -1).length}
|
|
331
|
+
|
|
332
|
+
Full output:
|
|
333
|
+
#{records.join("\n ")}
|
|
334
|
+
|
|
335
|
+
Record at index #{index}:
|
|
336
|
+
"#{record}"
|
|
337
|
+
ERROR
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
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
|