@@ -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" ) ]
17151745fn 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,
0 commit comments