@@ -160,7 +160,12 @@ pub fn add_worktree(
160160 create_branch : bool ,
161161 track : Option < & str > ,
162162) -> Result < ( ) , String > {
163- if let Some ( track_branch) = track {
163+ let normalized_track = match track {
164+ Some ( track_branch) => Some ( normalize_tracking_reference_input ( track_branch) ?) ,
165+ None => None ,
166+ } ;
167+
168+ if let Some ( track_branch) = normalized_track. as_deref ( ) {
164169 ensure_tracking_reference ( context, track_branch) ?;
165170 }
166171
@@ -169,11 +174,11 @@ pub fn add_worktree(
169174 normalized_worktree_path. as_str ( ) ,
170175 branch_name,
171176 create_branch,
172- track ,
177+ normalized_track . as_deref ( ) ,
173178 ) ;
174179
175180 git_raw ( context, & args) . map_err ( |e| format ! ( "Failed to add worktree: {}" , e) ) ?;
176- if let Some ( track_branch) = track {
181+ if let Some ( track_branch) = normalized_track . as_deref ( ) {
177182 set_branch_upstream ( context, branch_name, track_branch) ?;
178183 }
179184 Ok ( ( ) )
@@ -240,6 +245,18 @@ fn reference_exists(context: &RepoContext, reference: &str) -> bool {
240245 git_raw ( context, & [ "rev-parse" , "--verify" , reference] ) . is_ok ( )
241246}
242247
248+ pub fn normalize_tracking_reference_input ( reference : & str ) -> Result < String , String > {
249+ let normalized = trim_trailing_branch_slashes ( reference) ;
250+ let ( remote, branch) = parse_remote_tracking_reference ( normalized)
251+ . ok_or_else ( || invalid_tracking_reference ( reference) ) ?;
252+
253+ if !is_valid_ref_component ( remote) || !is_valid_ref_path ( branch) {
254+ return Err ( invalid_tracking_reference ( reference) ) ;
255+ }
256+
257+ Ok ( format ! ( "{}/{}" , remote, branch) )
258+ }
259+
243260fn parse_remote_tracking_reference ( reference : & str ) -> Option < ( & str , & str ) > {
244261 let normalized = if let Some ( rest) = reference. strip_prefix ( "refs/remotes/" ) {
245262 rest
@@ -261,6 +278,34 @@ pub fn tracked_branch_name(reference: &str) -> Option<&str> {
261278 parse_remote_tracking_reference ( reference) . map ( |( _, branch) | branch)
262279}
263280
281+ fn invalid_tracking_reference ( reference : & str ) -> String {
282+ format ! (
283+ "Invalid tracking branch '{}'. Use '<remote>/<branch>' or 'refs/remotes/<remote>/<branch>'." ,
284+ reference
285+ )
286+ }
287+
288+ fn is_valid_ref_path ( path : & str ) -> bool {
289+ !path. is_empty ( )
290+ && !path. contains ( "@{" )
291+ && !path. ends_with ( '.' )
292+ && path. split ( '/' ) . all ( is_valid_ref_component)
293+ }
294+
295+ fn is_valid_ref_component ( component : & str ) -> bool {
296+ !component. is_empty ( )
297+ && component != "."
298+ && component != ".."
299+ && !component. starts_with ( '.' )
300+ && !component. ends_with ( ".lock" )
301+ && !component. contains ( ".." )
302+ && !component. chars ( ) . any ( contains_invalid_ref_char)
303+ }
304+
305+ fn contains_invalid_ref_char ( ch : char ) -> bool {
306+ ch. is_ascii_control ( ) || matches ! ( ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' )
307+ }
308+
264309fn set_branch_upstream (
265310 context : & RepoContext ,
266311 branch_name : & str ,
@@ -407,22 +452,22 @@ fn parse_worktree_lines(output: &str) -> Vec<PartialWorktree> {
407452 } ;
408453
409454 for line in output. trim ( ) . lines ( ) {
410- if line. starts_with ( "worktree " ) {
455+ if let Some ( path ) = line. strip_prefix ( "worktree " ) {
411456 if current. path . is_some ( ) && !current. is_bare {
412457 worktrees. push ( current) ;
413458 }
414459 current = PartialWorktree {
415- path : Some ( line [ 9 .. ] . to_string ( ) ) ,
460+ path : Some ( path . to_string ( ) ) ,
416461 head : None ,
417462 branch : None ,
418463 is_locked : false ,
419464 is_prunable : false ,
420465 is_bare : false ,
421466 } ;
422- } else if line. starts_with ( "HEAD " ) {
423- current. head = Some ( line [ 5 .. ] . to_string ( ) ) ;
424- } else if line. starts_with ( "branch " ) {
425- current. branch = Some ( line [ 7 .. ] . replace ( "refs/heads/" , "" ) ) ;
467+ } else if let Some ( head ) = line. strip_prefix ( "HEAD " ) {
468+ current. head = Some ( head . to_string ( ) ) ;
469+ } else if let Some ( branch ) = line. strip_prefix ( "branch " ) {
470+ current. branch = Some ( branch . replace ( "refs/heads/" , "" ) ) ;
426471 } else if line == "detached" {
427472 current. branch = Some ( DETACHED_HEAD . to_string ( ) ) ;
428473 } else if line == "locked" {
@@ -720,6 +765,32 @@ mod tests {
720765 assert_eq ! ( parse_remote_tracking_reference( "origin" ) , None ) ;
721766 }
722767
768+ #[ test]
769+ fn normalize_tracking_reference_input_trims_trailing_slash ( ) {
770+ assert_eq ! (
771+ normalize_tracking_reference_input( "origin/feature/test/" ) . unwrap( ) ,
772+ "origin/feature/test"
773+ ) ;
774+ }
775+
776+ #[ test]
777+ fn normalize_tracking_reference_input_normalizes_full_ref ( ) {
778+ assert_eq ! (
779+ normalize_tracking_reference_input( "refs/remotes/origin/feature/test/" ) . unwrap( ) ,
780+ "origin/feature/test"
781+ ) ;
782+ }
783+
784+ #[ test]
785+ fn normalize_tracking_reference_input_rejects_empty_branch ( ) {
786+ assert ! ( normalize_tracking_reference_input( "origin/" ) . is_err( ) ) ;
787+ }
788+
789+ #[ test]
790+ fn normalize_tracking_reference_input_rejects_empty_path_segment ( ) {
791+ assert ! ( normalize_tracking_reference_input( "origin/feature//test" ) . is_err( ) ) ;
792+ }
793+
723794 #[ test]
724795 fn tracked_branch_name_returns_remote_branch_part ( ) {
725796 assert_eq ! (
0 commit comments