Skip to content

Commit eeed2ee

Browse files
matt2eclaude
andauthored
feat: improve PR button UX with push support and conditional visibility (#89)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ca8b0e4 commit eeed2ee

File tree

8 files changed

+865
-64
lines changed

8 files changed

+865
-64
lines changed

staged/src-tauri/Cargo.lock

Lines changed: 338 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

staged/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ tauri-plugin-clipboard-manager = "2.3.2"
4747
tauri-plugin-window-state = "2.4.1"
4848
reqwest = { version = "0.13.1", features = ["json"] }
4949
tokio = { version = "1.49.0", features = ["sync", "process", "io-util", "macros", "rt-multi-thread"] }
50-
open = "5"
50+
tauri-plugin-opener = "2"
5151

5252
# Agent Client Protocol (ACP) for AI integration
5353
agent-client-protocol = "0.9"

staged/src-tauri/src/git/github.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ fn get_github_token() -> Result<String, GitError> {
454454
}
455455

456456
/// Get the GitHub owner/repo from the repo's origin remote.
457-
fn get_github_repo(repo: &Path) -> Result<(String, String), GitError> {
457+
pub fn get_github_repo(repo: &Path) -> Result<(String, String), GitError> {
458458
use super::cli;
459459

460460
let url = cli::run(repo, &["remote", "get-url", "origin"])?;

staged/src-tauri/src/git/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub use refs::{
2424
pub use types::*;
2525
pub use worktree::{
2626
branch_exists, create_worktree, create_worktree_for_existing_branch, create_worktree_from_pr,
27-
get_commits_since_base, get_full_commit_log, get_head_sha, get_parent_commit, list_worktrees,
28-
remove_worktree, reset_to_commit, switch_branch, update_branch_from_pr, worktree_path_for,
29-
CommitInfo, UpdateFromPrResult,
27+
get_commits_since_base, get_full_commit_log, get_head_sha, get_parent_commit,
28+
has_unpushed_commits, list_worktrees, remove_worktree, reset_to_commit, switch_branch,
29+
update_branch_from_pr, worktree_path_for, CommitInfo, UpdateFromPrResult,
3030
};

staged/src-tauri/src/git/worktree.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,23 @@ pub fn switch_branch(worktree: &Path, branch_name: &str) -> Result<(), GitError>
499499
Ok(())
500500
}
501501

502+
/// Check if the local branch has commits not yet pushed to the remote.
503+
///
504+
/// Compares the local HEAD with `origin/<branch>`. Returns `true` if there
505+
/// are commits in the local branch that are not in the remote tracking branch.
506+
/// Returns `false` if the remote tracking branch doesn't exist (e.g., never pushed).
507+
pub fn has_unpushed_commits(worktree: &Path, branch: &str) -> Result<bool, GitError> {
508+
let remote_ref = format!("origin/{branch}");
509+
// Check that the remote ref exists first
510+
if cli::run(worktree, &["rev-parse", "--verify", &remote_ref]).is_err() {
511+
// Remote tracking branch doesn't exist — treat as "all commits are unpushed"
512+
// but only if there are local commits at all
513+
return Ok(false);
514+
}
515+
let output = cli::run(worktree, &["rev-list", &format!("{remote_ref}..HEAD")])?;
516+
Ok(!output.trim().is_empty())
517+
}
518+
502519
#[cfg(test)]
503520
mod tests {
504521
use super::*;

staged/src-tauri/src/lib.rs

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,6 +1710,36 @@ This is critical - the application parses this to link the PR.
17101710
Ok(session.id)
17111711
}
17121712

1713+
/// Build the GitHub PR URL for a branch from its remote origin and PR number.
1714+
///
1715+
/// Parses the `origin` remote URL to extract the GitHub owner/repo, then
1716+
/// returns `https://github.com/{owner}/{repo}/pull/{pr_number}`.
1717+
#[tauri::command(rename_all = "camelCase")]
1718+
fn get_pr_url(
1719+
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
1720+
branch_id: String,
1721+
pr_number: u64,
1722+
) -> Result<String, String> {
1723+
let store = get_store(&store)?;
1724+
1725+
let branch = store
1726+
.get_branch(&branch_id)
1727+
.map_err(|e| e.to_string())?
1728+
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;
1729+
1730+
let project = store
1731+
.get_project(&branch.project_id)
1732+
.map_err(|e| e.to_string())?
1733+
.ok_or_else(|| format!("Project not found: {}", branch.project_id))?;
1734+
1735+
let repo_path = Path::new(&project.repo_path);
1736+
let (owner, repo_name) = git::github::get_github_repo(repo_path).map_err(|e| e.to_string())?;
1737+
1738+
Ok(format!(
1739+
"https://github.com/{owner}/{repo_name}/pull/{pr_number}"
1740+
))
1741+
}
1742+
17131743
/// Update the PR number for a branch.
17141744
#[tauri::command(rename_all = "camelCase")]
17151745
fn update_branch_pr(
@@ -1722,14 +1752,148 @@ fn update_branch_pr(
17221752
.map_err(|e| e.to_string())
17231753
}
17241754

1755+
/// Check if a branch has commits that haven't been pushed to the remote.
1756+
#[tauri::command(rename_all = "camelCase")]
1757+
fn has_unpushed_commits(
1758+
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
1759+
branch_id: String,
1760+
) -> Result<bool, String> {
1761+
let store = get_store(&store)?;
1762+
1763+
let branch = store
1764+
.get_branch(&branch_id)
1765+
.map_err(|e| e.to_string())?
1766+
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;
1767+
1768+
let workdir = store
1769+
.get_workdir_for_branch(&branch_id)
1770+
.map_err(|e| e.to_string())?
1771+
.ok_or_else(|| format!("No worktree for branch: {branch_id}"))?;
1772+
1773+
git::has_unpushed_commits(Path::new(&workdir.path), &branch.branch_name)
1774+
.map_err(|e| e.to_string())
1775+
}
1776+
1777+
/// Push a branch to its remote by kicking off an agent session.
1778+
///
1779+
/// The agent runs `git push` and can diagnose and fix pre-push hook
1780+
/// failures or other push errors. Returns the session ID so the
1781+
/// frontend can track progress (same pattern as `create_pr`).
1782+
///
1783+
/// For remote branches the session runs inside the Blox workspace.
1784+
#[tauri::command(rename_all = "camelCase")]
1785+
fn push_branch(
1786+
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
1787+
registry: tauri::State<'_, Arc<session_runner::SessionRegistry>>,
1788+
app_handle: tauri::AppHandle,
1789+
branch_id: String,
1790+
provider: Option<String>,
1791+
force: Option<bool>,
1792+
) -> Result<String, String> {
1793+
let store = get_store(&store)?;
1794+
1795+
let branch = store
1796+
.get_branch(&branch_id)
1797+
.map_err(|e| e.to_string())?
1798+
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;
1799+
1800+
let project = store
1801+
.get_project(&branch.project_id)
1802+
.map_err(|e| e.to_string())?
1803+
.ok_or_else(|| format!("Project not found: {}", branch.project_id))?;
1804+
1805+
let is_remote = branch.branch_type == store::BranchType::Remote;
1806+
1807+
let (working_dir, workspace_name) = if is_remote {
1808+
let repo_path = PathBuf::from(&project.repo_path);
1809+
(repo_path, branch.workspace_name.clone())
1810+
} else {
1811+
let workdir = store
1812+
.get_workdir_for_branch(&branch_id)
1813+
.map_err(|e| e.to_string())?
1814+
.ok_or_else(|| format!("No worktree for branch: {branch_id}"))?;
1815+
1816+
let mut working_dir = PathBuf::from(&workdir.path);
1817+
if let Some(ref subpath) = project.subpath {
1818+
working_dir = working_dir.join(subpath);
1819+
}
1820+
(working_dir, None)
1821+
};
1822+
1823+
let force = force.unwrap_or(false);
1824+
1825+
let prompt = if force {
1826+
format!(
1827+
r#"<action>
1828+
Push the current branch to the remote using force-with-lease.
1829+
1830+
Run: `git push -u origin {branch_name} --force-with-lease`
1831+
1832+
If the push fails due to pre-push hook errors, read the error output, fix the underlying issue, and retry the push.
1833+
1834+
The push must succeed before you finish.
1835+
</action>"#,
1836+
branch_name = branch.branch_name,
1837+
)
1838+
} else {
1839+
format!(
1840+
r#"<action>
1841+
Push the current branch to the remote.
1842+
1843+
Run: `git push -u origin {branch_name}`
1844+
1845+
IMPORTANT: You MUST NOT use --force, --force-with-lease, or any force-push variant. Only a normal push is allowed.
1846+
1847+
If the push fails due to pre-push hook errors, read the error output, fix the underlying issue, and retry the push.
1848+
1849+
If the push is rejected because the remote has commits that would be lost (non-fast-forward rejection), do NOT attempt to fix it. Instead, output the following marker on its own line and stop:
1850+
PUSH_REJECTED: NON_FAST_FORWARD
1851+
1852+
For any other failure, diagnose the problem and fix it, then retry the push.
1853+
1854+
The push must succeed before you finish (unless you output the non-fast-forward marker above).
1855+
</action>"#,
1856+
branch_name = branch.branch_name,
1857+
)
1858+
};
1859+
1860+
// Create the session
1861+
let mut session = store::Session::new_running(&prompt, &working_dir);
1862+
if let Some(ref p) = provider {
1863+
session = session.with_provider(p);
1864+
}
1865+
store.create_session(&session).map_err(|e| e.to_string())?;
1866+
1867+
session_runner::start_session(
1868+
session_runner::SessionConfig {
1869+
session_id: session.id.clone(),
1870+
prompt,
1871+
working_dir,
1872+
agent_session_id: None,
1873+
pre_head_sha: None,
1874+
provider,
1875+
workspace_name,
1876+
},
1877+
store,
1878+
app_handle,
1879+
Arc::clone(&registry),
1880+
)?;
1881+
1882+
Ok(session.id)
1883+
}
1884+
17251885
// =============================================================================
17261886
// Utilities
17271887
// =============================================================================
17281888

17291889
/// Open a URL in the user's default browser.
17301890
#[tauri::command]
1731-
fn open_url(url: String) -> Result<(), String> {
1732-
open::that(&url).map_err(|e| format!("Failed to open URL: {e}"))
1891+
fn open_url(app_handle: tauri::AppHandle, url: String) -> Result<(), String> {
1892+
use tauri_plugin_opener::OpenerExt;
1893+
app_handle
1894+
.opener()
1895+
.open_url(&url, None::<&str>)
1896+
.map_err(|e| format!("Failed to open URL: {e}"))
17331897
}
17341898

17351899
/// Check whether the `sq` CLI is available on this system.
@@ -1900,6 +2064,7 @@ pub fn run() {
19002064
tauri::Builder::default()
19012065
.plugin(tauri_plugin_dialog::init())
19022066
.plugin(tauri_plugin_clipboard_manager::init())
2067+
.plugin(tauri_plugin_opener::init())
19032068
.plugin(
19042069
tauri_plugin_window_state::Builder::new()
19052070
.with_state_flags(
@@ -2120,7 +2285,10 @@ pub fn run() {
21202285
list_pull_requests,
21212286
list_issues,
21222287
create_pr,
2288+
get_pr_url,
21232289
update_branch_pr,
2290+
has_unpushed_commits,
2291+
push_branch,
21242292
open_url,
21252293
is_sq_available,
21262294
get_available_openers,

staged/src/lib/commands.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,28 @@ export function createPr(branchId: string, provider?: string): Promise<string> {
440440
return invoke('create_pr', { branchId, provider: provider ?? null });
441441
}
442442

443+
/** Build the GitHub PR URL from the repo's origin remote and a PR number. */
444+
export function getPrUrl(branchId: string, prNumber: number): Promise<string> {
445+
return invoke('get_pr_url', { branchId, prNumber });
446+
}
447+
443448
/** Update the PR number stored for a branch. */
444449
export function updateBranchPr(branchId: string, prNumber: number | null): Promise<void> {
445450
return invoke('update_branch_pr', { branchId, prNumber });
446451
}
452+
453+
/** Check whether a branch has local commits not yet pushed to the remote. */
454+
export function hasUnpushedCommits(branchId: string): Promise<boolean> {
455+
return invoke('has_unpushed_commits', { branchId });
456+
}
457+
458+
/** Push a branch to its remote via an agent session.
459+
* The agent runs git push and can fix pre-push hook failures.
460+
* Returns the session ID so the frontend can track progress. */
461+
export function pushBranch(branchId: string, provider?: string, force?: boolean): Promise<string> {
462+
return invoke('push_branch', {
463+
branchId,
464+
provider: provider ?? null,
465+
force: force ?? null,
466+
});
467+
}

0 commit comments

Comments
 (0)