Skip to content

Commit 6f8f43a

Browse files
committed
fix(branch): parse slash remote names
1 parent 9524654 commit 6f8f43a

5 files changed

Lines changed: 301 additions & 16 deletions

File tree

lib/git/branch_info.rb

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,10 @@ module Git
1919
# {Git::BranchInfo#refname} and normalized short-form refs (e.g., `main`,
2020
# `remotes/origin/main`) used elsewhere.
2121
#
22-
# @note This regex assumes remote names do not contain '/'. If a remote name
23-
# contains '/', parsing will be incorrect. For example, 'remotes/team/upstream/main'
24-
# would parse as remote_name='team' instead of 'team/upstream'. This is an inherent
25-
# ambiguity in git refnames that can only be resolved with knowledge of configured
26-
# remotes. See: https://github.com/ruby-git/ruby-git/issues/919
22+
# @note This regex is a fallback for branch refnames parsed without configured
23+
# remote context. Remote names containing '/' can only be resolved reliably
24+
# when the parser is given the configured remote names. See:
25+
# https://github.com/ruby-git/ruby-git/issues/919
2726
#
2827
# @api private
2928
BRANCH_REFNAME_REGEXP = %r{
@@ -34,6 +33,10 @@ module Git
3433
\z # end of string
3534
}x
3635

36+
# Sentinel for distinguishing omitted BranchInfo remote_name from explicit nil
37+
REMOTE_NAME_NOT_GIVEN = Object.new.freeze
38+
private_constant :REMOTE_NAME_NOT_GIVEN
39+
3740
# Value object representing branch metadata from git branch output
3841
#
3942
# This is a lightweight, immutable data structure returned by branch listing
@@ -84,6 +87,11 @@ module Git
8487
# @return [String] the branch refname (e.g., 'refs/heads/main',
8588
# 'refs/remotes/origin/main')
8689
#
90+
# @!attribute [r] remote_name
91+
#
92+
# @return [String, nil] the resolved or fallback-derived remote name, or nil
93+
# for local branches
94+
#
8795
# @!attribute [r] target_oid
8896
#
8997
# The commit object ID (SHA) that this branch points to (aka HEAD)
@@ -138,7 +146,64 @@ module Git
138146
# @note This is the raw refname snapshot from when the branch list was read.
139147
# It does not reflect live git state after the snapshot was taken.
140148
#
141-
BranchInfo = Data.define(:refname, :target_oid, :current, :worktree_path, :symref, :upstream) do
149+
BranchInfo = Data.define(:refname, :remote_name, :target_oid, :current, :worktree_path, :symref, :upstream) do
150+
# @param refname [String] the full branch refname
151+
#
152+
# @param remote_name [String, nil] resolved remote name, nil for local branches,
153+
# or omitted to derive from `refname`
154+
#
155+
# @param target_oid [String, nil] the commit object ID, or nil for unborn branches
156+
#
157+
# @param current [Boolean] whether this branch is currently checked out
158+
#
159+
# @param worktree_path [String, nil] path to another linked worktree, or nil
160+
#
161+
# @param symref [String, nil] symbolic reference target, or nil
162+
#
163+
# @param upstream [String, nil] upstream refname, or nil
164+
#
165+
def initialize(refname:, target_oid:, current:, worktree_path:, symref:, upstream:, # rubocop:disable Metrics/ParameterLists
166+
remote_name: REMOTE_NAME_NOT_GIVEN)
167+
remote_name = self.class.fallback_remote_name(refname) if remote_name.equal?(REMOTE_NAME_NOT_GIVEN)
168+
self.class.validate_remote_name!(refname, remote_name)
169+
170+
super
171+
end
172+
173+
# @param refname [String] the branch refname to validate
174+
#
175+
# @param remote_name [String, nil] the remote name to validate
176+
#
177+
# @return [void]
178+
#
179+
# @raise [ArgumentError] if the remote name contradicts the refname type
180+
def self.validate_remote_name!(refname, remote_name)
181+
if remote_tracking_refname?(refname)
182+
unless remote_name.is_a?(String) && !remote_name.empty?
183+
raise ArgumentError, 'remote_name must be a non-empty String for remote-tracking refname'
184+
end
185+
186+
remote_ref_prefix = %r{\A(?:refs/)?remotes/#{Regexp.escape(remote_name)}/}
187+
raise ArgumentError, 'remote_name must match remote-tracking refname' unless refname.match?(remote_ref_prefix)
188+
elsif !remote_name.nil?
189+
raise ArgumentError, 'remote_name must be nil for local branch refname'
190+
end
191+
end
192+
193+
# @param refname [String] the branch refname to parse
194+
#
195+
# @return [String, nil] the regex-derived remote name, or nil for local branches
196+
def self.fallback_remote_name(refname)
197+
refname.match(Git::BRANCH_REFNAME_REGEXP)[:remote_name]
198+
end
199+
200+
# @param refname [String] the branch refname to inspect
201+
#
202+
# @return [Boolean] true if the refname is a remote-tracking refname
203+
def self.remote_tracking_refname?(refname)
204+
refname.match?(%r{\A(?:refs/)?remotes/[^/]+/.+})
205+
end
206+
142207
# @return [Boolean] always false for BranchInfo (see DetachedHeadInfo for detached state)
143208
def detached? = false
144209

@@ -147,7 +212,12 @@ def unborn? = target_oid.nil?
147212

148213
# @return [String] the short branch name without any remote or heads prefix
149214
# (e.g., 'main' or 'feature/foo')
150-
def short_name = refname.match(Git::BRANCH_REFNAME_REGEXP)[:branch_name]
215+
def short_name
216+
return refname.delete_prefix('refs/heads/') if remote_name.nil?
217+
218+
remote_ref_prefix = %r{\A(?:refs/)?remotes/#{Regexp.escape(remote_name)}/}
219+
refname.sub(remote_ref_prefix, '')
220+
end
151221

152222
# @return [Boolean] true if this is the currently checked out branch
153223
def current? = current
@@ -161,9 +231,6 @@ def symref? = !symref.nil?
161231
# @return [Boolean] true if this is a remote-tracking branch
162232
def remote? = !remote_name.nil?
163233

164-
# @return [String, nil] the name of the remote (e.g., 'origin'), or nil for local branches
165-
def remote_name = refname.match(Git::BRANCH_REFNAME_REGEXP)[:remote_name]
166-
167234
# @return [String] string representation (the full refname)
168235
def to_s = refname
169236
end

lib/git/parsers/branch.rb

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,45 +78,74 @@ module Branch
7878
#
7979
# @param stdout [String] output from git branch --list --format=...
8080
#
81+
# @param remote_names [Array<String>] configured remote names used to resolve
82+
# remote-tracking refs with slash-containing remote names
83+
#
8184
# @return [Array<Git::BranchInfo>] parsed branch information
8285
#
83-
def parse_list(stdout)
84-
stdout.split("\n").filter_map { |line| parse_branch_line(line) }
86+
def parse_list(stdout, remote_names: [])
87+
stdout.split("\n").filter_map { |line| parse_branch_line(line, remote_names: remote_names) }
8588
end
8689

8790
# Parse a single formatted branch line
8891
#
8992
# @param line [String] the line to parse (NUL-delimited fields)
9093
#
94+
# @param remote_names [Array<String>] configured remote names used to resolve
95+
# remote-tracking refs with slash-containing remote names
96+
#
9197
# @return [Git::BranchInfo, nil] branch info object, or nil if line should be skipped
9298
#
93-
def parse_branch_line(line)
99+
def parse_branch_line(line, remote_names: [])
94100
fields = line.split(FIELD_DELIMITER, 6)
95101

96102
return nil if non_branch_entry?(fields[0])
97103

98-
build_branch_info(fields)
104+
build_branch_info(fields, remote_names: remote_names)
99105
end
100106

101107
# Build a BranchInfo from parsed fields
102108
#
103109
# @param fields [Array<String>] the parsed fields:
104110
# [refname, objectname, head, worktreepath, symref, upstream]
105111
#
112+
# @param remote_names [Array<String>] configured remote names used to resolve
113+
# remote-tracking refs with slash-containing remote names
114+
#
106115
# @return [Git::BranchInfo] the branch info object
107116
#
108-
def build_branch_info(fields)
117+
def build_branch_info(fields, remote_names: [])
109118
raw_refname, objectname, head, worktreepath, symref, upstream = fields
110119
Git::BranchInfo.new(
111120
refname: raw_refname,
112121
target_oid: presence(objectname),
113122
current: head == '*',
114123
worktree_path: head == '*' ? nil : presence(worktreepath),
115124
symref: presence(symref),
116-
upstream: build_upstream_info(upstream)
125+
upstream: build_upstream_info(upstream),
126+
remote_name: resolve_remote_name(raw_refname, remote_names)
117127
)
118128
end
119129

130+
# Resolve a remote-tracking refname to a configured remote name
131+
#
132+
# @param refname [String] the branch refname to inspect
133+
#
134+
# @param remote_names [Array<String>] configured remote names
135+
#
136+
# @return [String, nil] the resolved remote name, or nil for local branches
137+
#
138+
def resolve_remote_name(refname, remote_names)
139+
remote_path = refname[%r{\A(?:refs/)?remotes/(.+)\z}, 1]
140+
return nil if remote_path.nil?
141+
142+
configured_remote_name = remote_names
143+
.select { |remote_name| remote_path.start_with?("#{remote_name}/") }
144+
.max_by(&:length)
145+
146+
configured_remote_name || Git::BranchInfo.fallback_remote_name(refname)
147+
end
148+
120149
# Check if the refname represents a detached HEAD state or non-branch entry
121150
#
122151
# Git outputs special entries for detached HEAD and non-branch states:

spec/integration/git/parsers/branch_spec.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,34 @@ def git_branch_output(*args)
110110
end
111111
end
112112

113+
context 'with a slash-containing remote name' do
114+
let(:bare_dir) { Dir.mktmpdir('bare_repo') }
115+
116+
after do
117+
FileUtils.rm_rf(bare_dir)
118+
end
119+
120+
before do
121+
write_file('file.txt')
122+
repo.add('file.txt')
123+
repo.commit('Initial commit')
124+
125+
Git.init(bare_dir, bare: true)
126+
repo.remote_add('team/upstream', bare_dir)
127+
repo.push('team/upstream', 'main')
128+
repo.fetch('team/upstream')
129+
end
130+
131+
it 'resolves the remote name using configured remote names' do
132+
output = git_branch_output('--remotes')
133+
result = described_class.parse_list(output, remote_names: ['team/upstream'])
134+
remote_branch = result.find { |branch| branch.refname == 'refs/remotes/team/upstream/main' }
135+
136+
expect(remote_branch.remote_name).to eq('team/upstream')
137+
expect(remote_branch.short_name).to eq('main')
138+
end
139+
end
140+
113141
context 'with detached HEAD' do
114142
before do
115143
write_file('file.txt')

0 commit comments

Comments
 (0)