Skip to content

Commit 218d747

Browse files
authored
fix: parsing repo name when creating pr (#131)
1 parent db0a445 commit 218d747

File tree

4 files changed

+193
-13
lines changed

4 files changed

+193
-13
lines changed

src/git/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ pub mod pull_request;
3434
pub use clone::{clone_repository, remove_repository};
3535
pub use common::Logger;
3636
pub use pull_request::{
37-
add_all_changes, commit_changes, create_and_checkout_branch, get_default_branch, has_changes,
38-
push_branch,
37+
add_all_changes, checkout_branch, commit_changes, create_and_checkout_branch,
38+
get_current_branch, get_default_branch, has_changes, push_branch,
3939
};

src/git/pull_request.rs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,13 @@ pub fn push_branch(repo_path: &str, branch_name: &str) -> Result<()> {
116116
.context("Failed to execute git push command")?;
117117

118118
if !output.status.success() {
119+
let stderr = String::from_utf8_lossy(&output.stderr);
120+
let stdout = String::from_utf8_lossy(&output.stdout);
119121
anyhow::bail!(
120-
"Failed to push branch: {}",
121-
String::from_utf8_lossy(&output.stderr)
122+
"Failed to push branch '{}' to remote 'origin':\nstderr: {}\nstdout: {}",
123+
branch_name,
124+
stderr.trim(),
125+
stdout.trim()
122126
);
123127
}
124128

@@ -159,3 +163,45 @@ pub fn get_default_branch(repo_path: &str) -> Result<String> {
159163
// Final fallback to default branch
160164
Ok(crate::constants::git::FALLBACK_BRANCH.to_string())
161165
}
166+
167+
/// Get the current branch name
168+
pub fn get_current_branch(repo_path: &str) -> Result<String> {
169+
let output = Command::new("git")
170+
.args(["branch", "--show-current"])
171+
.current_dir(repo_path)
172+
.output()
173+
.context("Failed to execute git branch command")?;
174+
175+
if !output.status.success() {
176+
anyhow::bail!(
177+
"Failed to get current branch: {}",
178+
String::from_utf8_lossy(&output.stderr)
179+
);
180+
}
181+
182+
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
183+
if branch.is_empty() {
184+
anyhow::bail!("No current branch (detached HEAD state?)");
185+
}
186+
187+
Ok(branch)
188+
}
189+
190+
/// Checkout an existing branch
191+
pub fn checkout_branch(repo_path: &str, branch_name: &str) -> Result<()> {
192+
let output = Command::new("git")
193+
.args(["checkout", branch_name])
194+
.current_dir(repo_path)
195+
.output()
196+
.context("Failed to execute git checkout command")?;
197+
198+
if !output.status.success() {
199+
anyhow::bail!(
200+
"Failed to checkout branch '{}': {}",
201+
branch_name,
202+
String::from_utf8_lossy(&output.stderr)
203+
);
204+
}
205+
206+
Ok(())
207+
}

src/github/api.rs

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,31 @@ use anyhow::Result;
88
use colored::*;
99
use 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.
102144
fn 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
}

tests/github_tests.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,20 @@ async fn test_create_pr_workspace_commit_message_fallback() {
199199
let result = create_pr_from_workspace(&repo, &options).await;
200200
assert!(result.is_ok());
201201

202-
// Check that the commit was made with the title
202+
// Get the created branch name (starts with "automated-changes-")
203203
let output = std::process::Command::new("git")
204-
.args(["log", "-1", "--pretty=format:%s"])
204+
.args(["branch", "--list", "automated-changes-*"])
205+
.current_dir(&repo_path)
206+
.output()
207+
.expect("git branch failed");
208+
209+
let branches = String::from_utf8(output.stdout).unwrap();
210+
let branch_name = branches.trim().trim_start_matches("* ").trim();
211+
assert!(branch_name.starts_with("automated-changes-"));
212+
213+
// Check that the commit was made with the title on the created branch
214+
let output = std::process::Command::new("git")
215+
.args(["log", "-1", "--pretty=format:%s", branch_name])
205216
.current_dir(&repo_path)
206217
.output()
207218
.expect("git log failed");
@@ -355,9 +366,9 @@ async fn test_create_pr_workspace_custom_branch_and_commit() {
355366
let branches = String::from_utf8(output.stdout).unwrap();
356367
assert!(branches.contains("custom-branch"));
357368

358-
// Verify custom commit message was used
369+
// Verify custom commit message was used on the custom-branch
359370
let output = std::process::Command::new("git")
360-
.args(["log", "-1", "--pretty=format:%s"])
371+
.args(["log", "-1", "--pretty=format:%s", "custom-branch"])
361372
.current_dir(&repo_path)
362373
.output()
363374
.expect("git log failed");

0 commit comments

Comments
 (0)