@@ -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
0 commit comments