diff --git a/crates/but/src/log/mod.rs b/crates/but/src/log/mod.rs index 71b14b2ae0..3f035d8f7a 100644 --- a/crates/but/src/log/mod.rs +++ b/crates/but/src/log/mod.rs @@ -1,3 +1,4 @@ +use but_core::RepositoryExt; use but_graph::VirtualBranchesTomlMetadata; use but_settings::AppSettings; use but_workspace::{ @@ -7,12 +8,23 @@ use but_workspace::{ use colored::Colorize; use gitbutler_command_context::CommandContext; use gitbutler_project::Project; +use gitbutler_repo::RepoCommands; use crate::id::CliId; pub(crate) fn commit_graph(project: &Project, json: bool) -> anyhow::Result<()> { let ctx = &mut CommandContext::open(project, AppSettings::load_from_default_path_creating()?)?; but_rules::process_rules(ctx).ok(); // TODO: this is doing double work (dependencies can be reused) + + // Get remote information for URL generation + let remotes = project.remotes().unwrap_or_default(); + let primary_remote = remotes.first(); + let gerrit_mode = ctx + .gix_repo()? + .git_settings()? + .gitbutler_gerrit_mode + .unwrap_or(false); + let stacks = stacks(ctx)? .iter() .filter_map(|s| s.id.map(|id| stack_details(ctx, id).map(|d| (id, d)))) @@ -63,6 +75,22 @@ pub(crate) fn commit_graph(project: &Project, json: bool) -> anyhow::Result<()> mark.clone().unwrap_or_default() ); mark = None; // show this on the first branch in the stack + + // Add URL for branch if remote is available + if let Some(remote) = primary_remote { + if let Some(remote_url) = &remote.url { + if let Some(branch_url) = crate::url_utils::generate_branch_url(remote_url, &branch.name.to_string(), gerrit_mode) { + let url_prefix = if gerrit_mode { "Changes:" } else { "Branch:" }; + println!( + "{}{} {} {}", + "│ ".repeat(nesting), + extra_space, + url_prefix.dimmed(), + branch_url.cyan().underline() + ); + } + } + } for (j, commit) in branch.upstream_commits.iter().enumerate() { let time_string = chrono::DateTime::from_timestamp_millis(commit.created_at as i64) .ok_or(anyhow::anyhow!("Could not parse timestamp"))? @@ -86,6 +114,22 @@ pub(crate) fn commit_graph(project: &Project, json: bool) -> anyhow::Result<()> extra_space, commit.message.to_string().lines().next().unwrap_or("") ); + + // Add URL for commit if remote is available + if let Some(remote) = primary_remote { + if let Some(remote_url) = &remote.url { + if let Some(commit_url) = crate::url_utils::generate_commit_url(remote_url, &commit.id.to_string(), gerrit_mode) { + let url_prefix = if gerrit_mode { "Change:" } else { "Commit:" }; + println!( + "{}{}┊ {} {}", + "│ ".repeat(nesting), + extra_space, + url_prefix.dimmed(), + commit_url.cyan().underline() + ); + } + } + } let bend = if stacked { "├" } else { "╭" }; if j == branch.upstream_commits.len() - 1 { println!("{}{}─╯", "│ ".repeat(nesting), bend); @@ -131,6 +175,21 @@ pub(crate) fn commit_graph(project: &Project, json: bool) -> anyhow::Result<()> "│ ".repeat(nesting), commit.message.to_string().lines().next().unwrap_or("") ); + + // Add URL for commit if remote is available + if let Some(remote) = primary_remote { + if let Some(remote_url) = &remote.url { + if let Some(commit_url) = crate::url_utils::generate_commit_url(remote_url, &commit.id.to_string(), gerrit_mode) { + let url_prefix = if gerrit_mode { "Change:" } else { "Commit:" }; + println!( + "{}│ {} {}", + "│ ".repeat(nesting), + url_prefix.dimmed(), + commit_url.cyan().underline() + ); + } + } + } if i == stacks.len() - 1 { if nesting == 0 { println!("│"); diff --git a/crates/but/src/main.rs b/crates/but/src/main.rs index cf13e042a0..581449a02a 100644 --- a/crates/but/src/main.rs +++ b/crates/but/src/main.rs @@ -22,6 +22,7 @@ mod oplog; mod push; mod rub; mod status; +mod url_utils; mod worktree; #[tokio::main] diff --git a/crates/but/src/push/mod.rs b/crates/but/src/push/mod.rs index 87457ee359..f782e064a0 100644 --- a/crates/but/src/push/mod.rs +++ b/crates/but/src/push/mod.rs @@ -1,9 +1,135 @@ use but_core::RepositoryExt; use but_settings::AppSettings; use but_workspace::StackId; -use gitbutler_branch_actions::internal::PushResult; +use colored::Colorize; use gitbutler_command_context::CommandContext; use gitbutler_project::Project; +use gitbutler_repo::RepoCommands; + +/// Display branch URL if available, or Gerrit review URLs for commits +fn show_branch_or_review_urls(project: &Project, branch_name: &str, gerrit_mode: bool) -> anyhow::Result<()> { + if gerrit_mode { + // For Gerrit, show review URLs for commits that were just pushed + show_gerrit_review_urls(project, branch_name) + } else { + // For GitHub/GitLab, show branch URL + show_branch_url(project, branch_name) + } +} + +/// Show Gerrit review URLs for commits on the branch +fn show_gerrit_review_urls(project: &Project, branch_name: &str) -> anyhow::Result<()> { + // Get the branch details to find commits with review URLs + let stacks = but_api::workspace::stacks(project.id, None)?; + + for stack in stacks { + let details = but_api::workspace::stack_details(project.id, stack.id)?; + + // Look for the branch that was just pushed + for branch in details.branch_details { + if branch.name.to_string() == branch_name { + println!(); + let mut found_any = false; + + // Show review URLs for commits that have them + for commit in &branch.commits { + if let Some(review_url) = &commit.gerrit_review_url { + if !found_any { + println!("Gerrit reviews:"); + found_any = true; + } + println!(" {}{}: {}", + &commit.id.to_string()[..2].blue().underline(), + &commit.id.to_string()[2..7].blue(), + review_url + ); + } + } + + if !found_any { + // Fallback to dashboard URL if no specific review URLs + if let Ok(remotes) = project.remotes() { + if let Some(remote) = remotes.first() { + if let Some(remote_url) = &remote.url { + if let Some(dashboard_url) = crate::url_utils::generate_branch_url(remote_url, branch_name, true) { + println!("View changes: {}", dashboard_url); + } + } + } + } + } + return Ok(()); + } + } + } + Ok(()) +} + +/// Show branch URL for non-Gerrit remotes +fn show_branch_url(project: &Project, branch_name: &str) -> anyhow::Result<()> { + if let Ok(remotes) = project.remotes() { + if let Some(remote) = remotes.first() { + if let Some(remote_url) = &remote.url { + if let Some(branch_url) = crate::url_utils::generate_branch_url(remote_url, branch_name, false) { + println!(); + println!("Branch URL: {}", branch_url); + } + } + } + } + Ok(()) +} + +/// Extract a user-friendly error message from push errors +fn extract_push_error_message(error: &but_api::error::Error) -> String { + // Use Debug formatting to get string representation + let error_str = format!("{:?}", error); + + // Look for common Gerrit error patterns + if error_str.contains("no new changes") { + return "No new changes to push (commit may already exist in Gerrit)".to_string(); + } + + if error_str.contains("remote rejected") { + // Try to extract the rejection reason + if let Some(start) = error_str.find("remote rejected") { + if let Some(paren_start) = error_str[start..].find('(') { + if let Some(paren_end) = error_str[start + paren_start..].find(')') { + let reason = &error_str[start + paren_start + 1..start + paren_start + paren_end]; + return format!("Remote rejected push: {}", reason); + } + } + } + return "Remote rejected the push".to_string(); + } + + if error_str.contains("failed to push some refs") { + return "Failed to push some refs to remote".to_string(); + } + + if error_str.contains("Permission denied") { + return "Permission denied - check your authentication credentials".to_string(); + } + + if error_str.contains("Could not resolve hostname") { + return "Could not resolve hostname - check your network connection".to_string(); + } + + // For other errors, try to extract the first meaningful line + let lines: Vec<&str> = error_str.lines().collect(); + for line in &lines { + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with("Error:") && !trimmed.starts_with("Caused by:") { + // Skip very long lines (like full git commands) + if trimmed.len() < 150 { + return trimmed.to_string(); + } + } + } + + // Fallback to first line if nothing better found + lines.first().unwrap_or(&"Unknown push error").trim().to_string() +} #[derive(Debug, clap::Parser)] pub struct Args { @@ -108,8 +234,10 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> // Convert CLI args to gerrit flag with validation let gerrit_flag = get_gerrit_flag(args, &branch_name, gerrit_mode)?; - // Call push_stack - let result: PushResult = but_api::stack::push_stack( + println!("Pushing branch '{}'...", branch_name); + + // Call push_stack and handle both success and error cases + let push_result = but_api::stack::push_stack( project.id, stack_id, args.with_force, @@ -117,13 +245,35 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> branch_name.clone(), args.run_hooks, gerrit_flag, - )?; - - println!("Push completed successfully"); - println!("Pushed to remote: {}", result.remote); - if !gerrit_mode && !result.branch_to_remote.is_empty() { - for (branch, remote_ref) in &result.branch_to_remote { - println!(" {} -> {}", branch, remote_ref); + ); + + match push_result { + Ok(result) => { + println!("Push completed successfully"); + println!("Pushed to remote: {}", result.remote); + if !gerrit_mode && !result.branch_to_remote.is_empty() { + for (branch, remote_ref) in &result.branch_to_remote { + println!(" {} -> {}", branch, remote_ref); + } + } + + // Show URL for successful push + if let Err(e) = show_branch_or_review_urls(project, &branch_name, gerrit_mode) { + eprintln!("Warning: Failed to show URLs: {}", e); + } + } + Err(e) => { + // Extract and display a more user-friendly error message + let error_msg = extract_push_error_message(&e); + println!("Push failed: {}", error_msg); + + // Still show URL even when push fails + if let Err(e) = show_branch_or_review_urls(project, &branch_name, gerrit_mode) { + eprintln!("Warning: Failed to show URLs: {}", e); + } + + // Exit with error code but don't propagate the full error to avoid duplicate output + std::process::exit(1); } } diff --git a/crates/but/src/url_utils.rs b/crates/but/src/url_utils.rs new file mode 100644 index 0000000000..2074f3c849 --- /dev/null +++ b/crates/but/src/url_utils.rs @@ -0,0 +1,174 @@ +/// Generate branch URL for GitHub remote +pub fn generate_github_branch_url(remote_url: &str, branch_name: &str) -> Option { + if let Some(repo_path) = extract_github_repo_path(remote_url) { + Some(format!( + "https://github.com/{}/tree/{}", + repo_path, branch_name + )) + } else { + None + } +} + +/// Generate commit URL for GitHub remote +pub fn generate_github_commit_url(remote_url: &str, commit_id: &str) -> Option { + if let Some(repo_path) = extract_github_repo_path(remote_url) { + Some(format!( + "https://github.com/{}/commit/{}", + repo_path, commit_id + )) + } else { + None + } +} + +/// Generate branch URL for GitLab remote +pub fn generate_gitlab_branch_url(remote_url: &str, branch_name: &str) -> Option { + if let Some((host, repo_path)) = extract_gitlab_info(remote_url) { + Some(format!( + "https://{}/{}/-/tree/{}", + host, repo_path, branch_name + )) + } else { + None + } +} + +/// Generate commit URL for GitLab remote +pub fn generate_gitlab_commit_url(remote_url: &str, commit_id: &str) -> Option { + if let Some((host, repo_path)) = extract_gitlab_info(remote_url) { + Some(format!( + "https://{}/{}/-/commit/{}", + host, repo_path, commit_id + )) + } else { + None + } +} + +/// Generate change URL for Gerrit remote +pub fn generate_gerrit_change_url(remote_url: &str) -> Option { + if let Some(base_url) = extract_gerrit_base_url(remote_url) { + Some(format!("{}/dashboard/self", base_url)) + } else { + None + } +} + +/// Generate appropriate URL for the remote and branch +pub fn generate_branch_url(remote_url: &str, branch_name: &str, is_gerrit: bool) -> Option { + if is_gerrit { + generate_gerrit_change_url(remote_url) + } else if remote_url.contains("github.com") { + generate_github_branch_url(remote_url, branch_name) + } else if remote_url.contains("gitlab") { + generate_gitlab_branch_url(remote_url, branch_name) + } else { + None + } +} + +/// Generate appropriate URL for the remote and commit +pub fn generate_commit_url(remote_url: &str, commit_id: &str, is_gerrit: bool) -> Option { + if is_gerrit { + generate_gerrit_change_url(remote_url) + } else if remote_url.contains("github.com") { + generate_github_commit_url(remote_url, commit_id) + } else if remote_url.contains("gitlab") { + generate_gitlab_commit_url(remote_url, commit_id) + } else { + None + } +} + +/// Extract GitHub repository path from various URL formats +fn extract_github_repo_path(url: &str) -> Option { + // Handle GitHub URLs: git@github.com:user/repo.git or https://github.com/user/repo.git + if !url.contains("github.com") { + return None; + } + + // Find the part after github.com + let after_github = if let Some(pos) = url.find("github.com:") { + &url[pos + 11..] // Skip "github.com:" + } else if let Some(pos) = url.find("github.com/") { + &url[pos + 11..] // Skip "github.com/" + } else { + return None; + }; + + // Extract user/repo, removing .git suffix if present + let repo_path = after_github.trim_end_matches(".git"); + let parts: Vec<&str> = repo_path.splitn(3, '/').collect(); + if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() { + Some(format!("{}/{}", parts[0], parts[1])) + } else { + None + } +} + +/// Extract GitLab host and repository path from various URL formats +fn extract_gitlab_info(url: &str) -> Option<(String, String)> { + // Handle GitLab URLs: git@gitlab.example.com:user/repo.git or https://gitlab.example.com/user/repo.git + if !url.contains("gitlab") { + return None; + } + + // Extract host and path + if let Some(colon_pos) = url.rfind(':') { + if let Some(at_pos) = url[..colon_pos].rfind('@') { + // SSH format: git@gitlab.example.com:user/repo.git + let host = &url[at_pos + 1..colon_pos]; + let path_part = &url[colon_pos + 1..]; + let repo_path = path_part.trim_end_matches(".git"); + let parts: Vec<&str> = repo_path.splitn(3, '/').collect(); + if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() { + return Some((host.to_string(), format!("{}/{}", parts[0], parts[1]))); + } + } + } else if url.starts_with("https://") { + // HTTPS format: https://gitlab.example.com/user/repo.git + let after_https = &url[8..]; // Skip "https://" + if let Some(slash_pos) = after_https.find('/') { + let host = &after_https[..slash_pos]; + let path_part = &after_https[slash_pos + 1..]; + let repo_path = path_part.trim_end_matches(".git"); + let parts: Vec<&str> = repo_path.splitn(3, '/').collect(); + if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() { + return Some((host.to_string(), format!("{}/{}", parts[0], parts[1]))); + } + } + } + None +} + +/// Extract Gerrit base URL from remote URL +fn extract_gerrit_base_url(url: &str) -> Option { + // Handle Gerrit URLs: ssh://user@gerrit.example.com:29418/project.git or https://gerrit.example.com/a/project.git + if url.contains("gerrit") || url.contains(":29418") { + if url.starts_with("ssh://") { + // SSH format: ssh://user@gerrit.example.com:29418/project.git or ssh://localhost:29418/project.git + let after_ssh = &url[6..]; // Skip "ssh://" + let host_part = if let Some(at_pos) = after_ssh.find('@') { + // Has user part: ssh://user@hostname:port/path + &after_ssh[at_pos + 1..] + } else { + // No user part: ssh://hostname:port/path + after_ssh + }; + + if let Some(colon_pos) = host_part.find(':') { + let host = &host_part[..colon_pos]; + return Some(format!("https://{}", host)); + } + } else if url.starts_with("https://") { + // HTTPS format: https://gerrit.example.com/a/project.git + let after_https = &url[8..]; // Skip "https://" + if let Some(slash_pos) = after_https.find('/') { + let host = &after_https[..slash_pos]; + return Some(format!("https://{}", host)); + } + } + } + None +} \ No newline at end of file