@@ -8,6 +8,31 @@ use anyhow::Result;
88use colored:: * ;
99use uuid:: Uuid ;
1010
11+ /// RAII guard to automatically restore the original branch on drop
12+ struct BranchGuard < ' a > {
13+ repo_path : String ,
14+ original_branch : Option < String > ,
15+ repo_name : & ' a str ,
16+ }
17+
18+ impl Drop for BranchGuard < ' _ > {
19+ fn drop ( & mut self ) {
20+ if let Some ( ref original) = self . original_branch
21+ && let Err ( e) = git:: checkout_branch ( & self . repo_path , original)
22+ {
23+ eprintln ! (
24+ "{} | {}" ,
25+ self . repo_name. cyan( ) . bold( ) ,
26+ format!(
27+ "Warning: Failed to restore original branch '{}': {}" ,
28+ original, e
29+ )
30+ . yellow( )
31+ ) ;
32+ }
33+ }
34+ }
35+
1136/// High-level function to create a PR from local changes
1237///
1338/// This function encapsulates the entire pull request creation flow:
@@ -27,6 +52,14 @@ pub async fn create_pr_from_workspace(repo: &Repository, options: &PrOptions) ->
2752 return Ok ( ( ) ) ;
2853 }
2954
55+ // Save the current branch to restore later using RAII guard
56+ let original_branch = git:: get_current_branch ( & repo_path) . ok ( ) ;
57+ let _branch_guard = BranchGuard {
58+ repo_path : repo_path. clone ( ) ,
59+ original_branch : original_branch. clone ( ) ,
60+ repo_name : & repo. name ,
61+ } ;
62+
3063 // Generate branch name if not provided
3164 let branch_name = options. branch_name . clone ( ) . unwrap_or_else ( || {
3265 format ! (
@@ -61,6 +94,12 @@ pub async fn create_pr_from_workspace(repo: &Repository, options: &PrOptions) ->
6194 "Pull request created:" . green( ) ,
6295 pr_url
6396 ) ;
97+ } else {
98+ println ! (
99+ "{} | {}" ,
100+ repo. name. cyan( ) . bold( ) ,
101+ "Branch created (not pushed, --create-only mode)" . yellow( )
102+ ) ;
64103 }
65104
66105 Ok ( ( ) )
@@ -99,16 +138,44 @@ async fn create_github_pr(
99138}
100139
101140/// Parse a GitHub URL to extract owner and repository name
141+ ///
142+ /// Supports both SSH (git@host:owner/repo) and HTTPS (https://host/owner/repo) formats.
143+ /// Works with GitHub, GitLab, Bitbucket, and other Git hosting providers.
102144fn parse_github_url ( url : & str ) -> Result < ( String , String ) > {
103145 let url = url. trim_end_matches ( '/' ) . trim_end_matches ( ".git" ) ;
104146
105- let parts: Vec < & str > = url. split ( '/' ) . collect ( ) ;
106- if parts. len ( ) < 2 {
107- anyhow:: bail!( "Invalid GitHub URL format: {url}" ) ;
147+ // Handle SSH format: git@host:owner/repo or user@host:owner/repo
148+ // The key indicator is the presence of '@' followed by ':' without '//'
149+ if let Some ( at_pos) = url. find ( '@' )
150+ && let Some ( colon_pos) = url[ at_pos..] . find ( ':' )
151+ {
152+ // Extract the path after the colon
153+ let path_start = at_pos + colon_pos + 1 ;
154+ let path = & url[ path_start..] ;
155+
156+ // Split owner/repo - use rsplit to handle nested paths like owner/group/repo
157+ let mut parts = path. rsplitn ( 2 , '/' ) ;
158+ let repo_name = parts. next ( ) . ok_or_else ( || {
159+ anyhow:: anyhow!( "Invalid SSH URL format: missing repo name in {}" , url)
160+ } ) ?;
161+ let owner = parts
162+ . next ( )
163+ . ok_or_else ( || anyhow:: anyhow!( "Invalid SSH URL format: missing owner in {}" , url) ) ?;
164+
165+ return Ok ( ( owner. to_string ( ) , repo_name. to_string ( ) ) ) ;
108166 }
109167
110- let repo_name = parts[ parts. len ( ) - 1 ] ;
111- let owner = parts[ parts. len ( ) - 2 ] ;
168+ // Handle HTTPS format: https://host/owner/repo
169+ // Use rsplit to efficiently get the last two segments
170+ let mut parts = url. rsplitn ( 3 , '/' ) ;
171+ let repo_name = parts
172+ . next ( )
173+ . filter ( |s| !s. is_empty ( ) )
174+ . ok_or_else ( || anyhow:: anyhow!( "Invalid URL format: missing repo name in {}" , url) ) ?;
175+ let owner = parts
176+ . next ( )
177+ . filter ( |s| !s. is_empty ( ) )
178+ . ok_or_else ( || anyhow:: anyhow!( "Invalid URL format: missing owner in {}" , url) ) ?;
112179
113180 Ok ( ( owner. to_string ( ) , repo_name. to_string ( ) ) )
114181}
@@ -326,4 +393,60 @@ mod tests {
326393
327394 assert_eq ! ( options_with_base. base_branch. unwrap( ) , "develop" ) ;
328395 }
396+
397+ #[ test]
398+ fn test_parse_github_url_https ( ) {
399+ // Test HTTPS URL parsing
400+ let ( owner, repo) =
401+ parse_github_url ( "https://github.com/example-org/example-repo" ) . unwrap ( ) ;
402+ assert_eq ! ( owner, "example-org" ) ;
403+ assert_eq ! ( repo, "example-repo" ) ;
404+
405+ // Test with .git suffix
406+ let ( owner, repo) = parse_github_url ( "https://github.com/test-org/test-repo.git" ) . unwrap ( ) ;
407+ assert_eq ! ( owner, "test-org" ) ;
408+ assert_eq ! ( repo, "test-repo" ) ;
409+
410+ // Test with trailing slash
411+ let ( owner, repo) = parse_github_url ( "https://github.com/owner/repo/" ) . unwrap ( ) ;
412+ assert_eq ! ( owner, "owner" ) ;
413+ assert_eq ! ( repo, "repo" ) ;
414+ }
415+
416+ #[ test]
417+ fn test_parse_github_url_ssh ( ) {
418+ // Test SSH URL parsing
419+ let ( owner, repo) = parse_github_url ( "git@github.com:example-org/example-repo" ) . unwrap ( ) ;
420+ assert_eq ! ( owner, "example-org" ) ;
421+ assert_eq ! ( repo, "example-repo" ) ;
422+
423+ // Test with .git suffix
424+ let ( owner, repo) = parse_github_url ( "git@github.com:test-org/test-repo.git" ) . unwrap ( ) ;
425+ assert_eq ! ( owner, "test-org" ) ;
426+ assert_eq ! ( repo, "test-repo" ) ;
427+
428+ // Test GitLab SSH format
429+ let ( owner, repo) = parse_github_url ( "git@gitlab.com:mycompany/myrepo" ) . unwrap ( ) ;
430+ assert_eq ! ( owner, "mycompany" ) ;
431+ assert_eq ! ( repo, "myrepo" ) ;
432+
433+ // Test Bitbucket SSH format
434+ let ( owner, repo) = parse_github_url ( "git@bitbucket.org:workspace/repository.git" ) . unwrap ( ) ;
435+ assert_eq ! ( owner, "workspace" ) ;
436+ assert_eq ! ( repo, "repository" ) ;
437+ }
438+
439+ #[ test]
440+ fn test_parse_github_url_invalid ( ) {
441+ // Test truly invalid URLs - single words or malformed SSH
442+ assert ! ( parse_github_url( "invalid" ) . is_err( ) ) ;
443+ assert ! ( parse_github_url( "git@github.com:" ) . is_err( ) ) ;
444+ assert ! ( parse_github_url( "git@github.com:owner" ) . is_err( ) ) ;
445+
446+ // Note: These cases actually succeed because they technically have owner/repo segments:
447+ // - "https://github.com/" parses as owner="github.com", repo=""
448+ // - "https://github.com/owner" parses as owner="github.com", repo="owner"
449+ // These would fail at the API call level, not at URL parsing level
450+ // To catch these, we'd need to validate against known hosts or check for empty strings
451+ }
329452}
0 commit comments