From af30cc9d013a295f33651dfd2486a894e1b96557 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Fri, 17 Oct 2025 15:22:20 +0200 Subject: [PATCH 1/4] Show branch or change URL after push Enhance push output to display a link to the pushed branch or Gerrit change so users can quickly view their changes on the remote. Added URL generation helpers for GitHub, GitLab and Gerrit (parsing multiple remote URL forms), a wrapper to pick the right URL, and logic to lookup the project's remote URL and print a Branch URL or View changes link after a successful push. Also included a dbg! call for the push result and minor output formatting adjustments. --- crates/but/src/push/mod.rs | 153 +++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/crates/but/src/push/mod.rs b/crates/but/src/push/mod.rs index 87457ee359..0871db57a2 100644 --- a/crates/but/src/push/mod.rs +++ b/crates/but/src/push/mod.rs @@ -4,6 +4,140 @@ use but_workspace::StackId; use gitbutler_branch_actions::internal::PushResult; use gitbutler_command_context::CommandContext; use gitbutler_project::Project; +use gitbutler_repo::RepoCommands; + +/// Generate branch URL for GitHub remote +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 branch URL for GitLab remote +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 change URL for Gerrit remote +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 + } +} + +/// 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 + let after_ssh = &url[6..]; // Skip "ssh://" + if let Some(at_pos) = after_ssh.find('@') { + let after_at = &after_ssh[at_pos + 1..]; + if let Some(colon_pos) = after_at.find(':') { + let host = &after_at[..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 +} + +/// Generate appropriate URL for the remote and branch +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 + } +} #[derive(Debug, clap::Parser)] pub struct Args { @@ -108,6 +242,8 @@ 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)?; + println!("Pushing branch '{}'...", branch_name); + // Call push_stack let result: PushResult = but_api::stack::push_stack( project.id, @@ -119,6 +255,8 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> gerrit_flag, )?; + dbg!(&result); + println!("Push completed successfully"); println!("Pushed to remote: {}", result.remote); if !gerrit_mode && !result.branch_to_remote.is_empty() { @@ -127,6 +265,21 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> } } + // Get remote URL and generate branch URL if possible + if let Ok(remotes) = project.remotes() { + if let Some(remote) = remotes.iter().find(|r| r.name.as_ref() == Some(&result.remote)) { + if let Some(remote_url) = &remote.url { + if let Some(branch_url) = generate_branch_url(remote_url, &branch_name, gerrit_mode) { + if gerrit_mode { + println!("View changes: {}", branch_url); + } else { + println!("Branch URL: {}", branch_url); + } + } + } + } + } + Ok(()) } From 2c1bd8462f7905e078b15d2f82957a0502b33c27 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Fri, 17 Oct 2025 15:29:41 +0200 Subject: [PATCH 2/4] Add branch/commit URLs to 'but log' output Show branch and commit web links in the but log output when a remote is available, including Gerrit-aware URLs. This adds URL generation helpers (crates/but/src/url_utils.rs), wires remote/gerrit detection into the log output, and prints per-branch and per-commit URLs (GitHub/GitLab/Gerrit) in the commit graph. It also reuses the new url_utils in push output and cleans up removed duplicate URL helper code from push/mod.rs. --- crates/but/src/log/mod.rs | 59 +++++++++++++ crates/but/src/main.rs | 1 + crates/but/src/push/mod.rs | 157 +++------------------------------ crates/but/src/url_utils.rs | 169 ++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 145 deletions(-) create mode 100644 crates/but/src/url_utils.rs 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 0871db57a2..f8b228761a 100644 --- a/crates/but/src/push/mod.rs +++ b/crates/but/src/push/mod.rs @@ -6,139 +6,6 @@ use gitbutler_command_context::CommandContext; use gitbutler_project::Project; use gitbutler_repo::RepoCommands; -/// Generate branch URL for GitHub remote -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 branch URL for GitLab remote -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 change URL for Gerrit remote -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 - } -} - -/// 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 - let after_ssh = &url[6..]; // Skip "ssh://" - if let Some(at_pos) = after_ssh.find('@') { - let after_at = &after_ssh[at_pos + 1..]; - if let Some(colon_pos) = after_at.find(':') { - let host = &after_at[..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 -} - -/// Generate appropriate URL for the remote and branch -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 - } -} - #[derive(Debug, clap::Parser)] pub struct Args { /// Branch name or CLI ID to push @@ -255,8 +122,6 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> gerrit_flag, )?; - dbg!(&result); - println!("Push completed successfully"); println!("Pushed to remote: {}", result.remote); if !gerrit_mode && !result.branch_to_remote.is_empty() { @@ -266,16 +131,18 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> } // Get remote URL and generate branch URL if possible - if let Ok(remotes) = project.remotes() { - if let Some(remote) = remotes.iter().find(|r| r.name.as_ref() == Some(&result.remote)) { - if let Some(remote_url) = &remote.url { - if let Some(branch_url) = generate_branch_url(remote_url, &branch_name, gerrit_mode) { - if gerrit_mode { - println!("View changes: {}", branch_url); - } else { - println!("Branch URL: {}", branch_url); - } - } + if let Ok(remotes) = project.remotes() + && let Some(remote_url) = remotes + .iter() + .find(|r| r.name.as_ref() == Some(&result.remote)) + .and_then(|remote| remote.url.as_ref()) + { + println!(); + if let Some(branch_url) = crate::url_utils::generate_branch_url(remote_url, &branch_name, gerrit_mode) { + if gerrit_mode { + println!("View changes: {}", branch_url); + } else { + println!("Branch URL: {}", branch_url); } } } diff --git a/crates/but/src/url_utils.rs b/crates/but/src/url_utils.rs new file mode 100644 index 0000000000..286f36ba4d --- /dev/null +++ b/crates/but/src/url_utils.rs @@ -0,0 +1,169 @@ +/// 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 + let after_ssh = &url[6..]; // Skip "ssh://" + if let Some(at_pos) = after_ssh.find('@') { + let after_at = &after_ssh[at_pos + 1..]; + if let Some(colon_pos) = after_at.find(':') { + let host = &after_at[..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 From c8663859660ab432a3bdd0f568878374ebcd961d Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Fri, 17 Oct 2025 15:35:09 +0200 Subject: [PATCH 3/4] Show Gerrit/branch URL and summarize push errors When pushing to Gerrit the post-push dialog sometimes created a review URL but did not display it; additionally push failures printed large, unhelpful error dumps. Add logic to always attempt to display a branch/review URL (when it can be generated) and extract a concise, user-friendly error message from known git/Gerrit error patterns so failures are shown succinctly while still showing the URL when available. Changes: - Add show_branch_url() to generate and print a branch or Gerrit "View changes" URL. - Add extract_push_error_message() to parse common push errors ("no new changes", "remote rejected", "failed to push some refs", permission/network errors) and return a short explanation. - Replace the previous direct push result handling with match handling: on Ok, print push details and URL; on Err, print the extracted short error message, still show the URL if available, and return the error. This improves UX by surfacing useful URLs even on failures and by providing readable, actionable error messages instead of large debug dumps. --- crates/but/src/push/mod.rs | 116 +++++++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/crates/but/src/push/mod.rs b/crates/but/src/push/mod.rs index f8b228761a..68fef3ee46 100644 --- a/crates/but/src/push/mod.rs +++ b/crates/but/src/push/mod.rs @@ -1,11 +1,79 @@ use but_core::RepositoryExt; use but_settings::AppSettings; use but_workspace::StackId; -use gitbutler_branch_actions::internal::PushResult; use gitbutler_command_context::CommandContext; use gitbutler_project::Project; use gitbutler_repo::RepoCommands; +/// Display branch URL if available +fn show_branch_url(project: &Project, branch_name: &str, gerrit_mode: bool) { + 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, gerrit_mode) { + println!(); + if gerrit_mode { + println!("View changes: {}", branch_url); + } else { + println!("Branch URL: {}", branch_url); + } + } + } + } + } +} + +/// 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 { /// Branch name or CLI ID to push @@ -111,8 +179,8 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> println!("Pushing branch '{}'...", branch_name); - // Call push_stack - let result: PushResult = but_api::stack::push_stack( + // 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, @@ -120,30 +188,30 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> branch_name.clone(), args.run_hooks, gerrit_flag, - )?; + ); + + 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); + } + } - 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 + show_branch_url(project, &branch_name, gerrit_mode); } - } + Err(e) => { + // Extract and display a more user-friendly error message + let error_msg = extract_push_error_message(&e); + println!("Push failed: {}", error_msg); - // Get remote URL and generate branch URL if possible - if let Ok(remotes) = project.remotes() - && let Some(remote_url) = remotes - .iter() - .find(|r| r.name.as_ref() == Some(&result.remote)) - .and_then(|remote| remote.url.as_ref()) - { - println!(); - if let Some(branch_url) = crate::url_utils::generate_branch_url(remote_url, &branch_name, gerrit_mode) { - if gerrit_mode { - println!("View changes: {}", branch_url); - } else { - println!("Branch URL: {}", branch_url); - } + // Still show URL even when push fails + show_branch_url(project, &branch_name, gerrit_mode); + + return Err(e.into()); } } From 500d40f945f58fc7095afefe55f013657d1d2bee Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Fri, 17 Oct 2025 15:42:29 +0200 Subject: [PATCH 4/4] Show Gerrit review URLs for pushed commits For Gerrit remotes we now display per-commit Gerrit review URLs (matching how `but status` shows Gerrit URLs) for the commits on the branch that was just pushed. If no individual review URLs are present we fall back to a dashboard/branch URL. For non-Gerrit remotes the existing branch URL logic is preserved. Added helper functions to fetch stack/branch details, format and colorize commit IDs, and improved Gerrit SSH host parsing in URL utilities. Also handle errors when showing URLs without failing the push output. --- crates/but/src/push/mod.rs | 84 ++++++++++++++++++++++++++++++++----- crates/but/src/url_utils.rs | 19 +++++---- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/crates/but/src/push/mod.rs b/crates/but/src/push/mod.rs index 68fef3ee46..f782e064a0 100644 --- a/crates/but/src/push/mod.rs +++ b/crates/but/src/push/mod.rs @@ -1,26 +1,83 @@ use but_core::RepositoryExt; use but_settings::AppSettings; use but_workspace::StackId; +use colored::Colorize; use gitbutler_command_context::CommandContext; use gitbutler_project::Project; use gitbutler_repo::RepoCommands; -/// Display branch URL if available -fn show_branch_url(project: &Project, branch_name: &str, gerrit_mode: bool) { +/// 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, gerrit_mode) { + if let Some(branch_url) = crate::url_utils::generate_branch_url(remote_url, branch_name, false) { println!(); - if gerrit_mode { - println!("View changes: {}", branch_url); - } else { - println!("Branch URL: {}", branch_url); - } + println!("Branch URL: {}", branch_url); } } } } + Ok(()) } /// Extract a user-friendly error message from push errors @@ -201,7 +258,9 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> } // Show URL for successful push - show_branch_url(project, &branch_name, gerrit_mode); + 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 @@ -209,9 +268,12 @@ pub fn handle(args: &Args, project: &Project, _json: bool) -> anyhow::Result<()> println!("Push failed: {}", error_msg); // Still show URL even when push fails - show_branch_url(project, &branch_name, gerrit_mode); + if let Err(e) = show_branch_or_review_urls(project, &branch_name, gerrit_mode) { + eprintln!("Warning: Failed to show URLs: {}", e); + } - return Err(e.into()); + // 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 index 286f36ba4d..2074f3c849 100644 --- a/crates/but/src/url_utils.rs +++ b/crates/but/src/url_utils.rs @@ -147,14 +147,19 @@ 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 + // SSH format: ssh://user@gerrit.example.com:29418/project.git or ssh://localhost:29418/project.git let after_ssh = &url[6..]; // Skip "ssh://" - if let Some(at_pos) = after_ssh.find('@') { - let after_at = &after_ssh[at_pos + 1..]; - if let Some(colon_pos) = after_at.find(':') { - let host = &after_at[..colon_pos]; - return Some(format!("https://{}", host)); - } + 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