diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 12caa5b9..946179db 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -613,6 +613,7 @@ pub enum CreatePrError { GithubCliNotLoggedIn, GitCliNotLoggedIn, GitCliNotInstalled, + TargetBranchNotFound { branch: String }, } pub async fn create_github_pr( @@ -646,6 +647,31 @@ pub async fn create_github_pr( let workspace_path = ensure_worktree_path(&deployment, &task_attempt).await?; + match deployment + .git() + .check_remote_branch_exists(&project.git_repo_path, &target_branch) + { + Ok(false) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::TargetBranchNotFound { + branch: target_branch.clone(), + }, + ))); + } + Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotLoggedIn, + ))); + } + Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => { + return Ok(ResponseJson(ApiResponse::error_with_data( + CreatePrError::GitCliNotInstalled, + ))); + } + Err(e) => return Err(ApiError::GitService(e)), + Ok(true) => {} + } + // Push the branch to GitHub first if let Err(e) = deployment .git() diff --git a/crates/services/src/services/git.rs b/crates/services/src/services/git.rs index faaa25f9..206b0ea9 100644 --- a/crates/services/src/services/git.rs +++ b/crates/services/src/services/git.rs @@ -1411,6 +1411,25 @@ impl GitService { } } + pub fn check_remote_branch_exists( + &self, + repo_path: &Path, + branch_name: &str, + ) -> Result { + let repo = self.open_repo(repo_path)?; + let default_remote_name = self.default_remote_name(&repo); + let remote = repo.find_remote(&default_remote_name)?; + + let remote_url = remote + .url() + .ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?; + + let git_cli = GitCli::new(); + git_cli + .check_remote_branch_exists(repo_path, remote_url, branch_name) + .map_err(|e| e.into()) + } + pub fn rename_local_branch( &self, worktree_path: &Path, diff --git a/crates/services/src/services/git/cli.rs b/crates/services/src/services/git/cli.rs index 9ad1ebad..be189d46 100644 --- a/crates/services/src/services/git/cli.rs +++ b/crates/services/src/services/git/cli.rs @@ -337,6 +337,29 @@ impl GitCli { } } + /// This directly queries the remote without fetching. + pub fn check_remote_branch_exists( + &self, + repo_path: &Path, + remote_url: &str, + branch_name: &str, + ) -> Result { + let envs = vec![(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0"))]; + + let args = [ + OsString::from("ls-remote"), + OsString::from("--heads"), + OsString::from(remote_url), + OsString::from(format!("refs/heads/{branch_name}")), + ]; + + match self.git_with_env(repo_path, args, &envs) { + Ok(output) => Ok(!output.trim().is_empty()), + Err(GitCliError::CommandFailed(msg)) => Err(self.classify_cli_error(msg)), + Err(err) => Err(err), + } + } + // Parse `git diff --name-status` output into structured entries. // Handles rename/copy scores like `R100` by matching the first letter. fn parse_name_status(output: &str) -> Vec { diff --git a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx index f34c8068..9e2926a4 100644 --- a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx @@ -189,6 +189,14 @@ const CreatePRDialogImpl = NiceModal.create( setError(result.message || t(gitCliErrorKey)); setGhCliHelp(null); return; + } else if (result.error.type === 'target_branch_not_found') { + setError( + t('createPrDialog.errors.targetBranchNotFound', { + branch: result.error.branch, + }) + ); + setGhCliHelp(null); + return; } } diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 8e9898e0..b6e69604 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -343,7 +343,8 @@ "repoNotFoundOrNoAccess": "Repository not found or no access. Please check your repository access and ensure you are authenticated.", "failedToCreate": "Failed to create GitHub PR", "gitCliNotLoggedIn": "Git is not authenticated. Run \"gh auth login\" (or configure Git credentials) and try again.", - "gitCliNotInstalled": "Git CLI is not installed. Install Git to create a PR." + "gitCliNotInstalled": "Git CLI is not installed. Install Git to create a PR.", + "targetBranchNotFound": "Target branch '{{branch}}' does not exist on remote. Please ensure the branch exists before creating a pull request." }, "loginRequired": { "title": "Sign in to create a pull request", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 17d914a4..498e90b1 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -119,7 +119,8 @@ "repoNotFoundOrNoAccess": "Repositorio no encontrado o sin acceso. Por favor verifica el acceso al repositorio y asegúrate de estar autenticado.", "failedToCreate": "Error al crear PR de GitHub", "gitCliNotLoggedIn": "Git no está autenticado. Ejecuta \"gh auth login\" (o configura las credenciales de Git) e inténtalo de nuevo.", - "gitCliNotInstalled": "Git CLI no está instalado. Instala Git para crear una PR." + "gitCliNotInstalled": "Git CLI no está instalado. Instala Git para crear una PR.", + "targetBranchNotFound": "La rama objetivo '{{branch}}' no existe en el remoto. Por favor, asegúrese de que la rama exista antes de crear una solicitud de extracción." }, "loginRequired": { "title": "Inicia sesión para crear un pull request", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 8e2edb57..163c32fd 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -119,7 +119,8 @@ "repoNotFoundOrNoAccess": "リポジトリが見つからないか、アクセス権がありません。リポジトリへのアクセス権を確認し、認証されていることを確認してください。", "failedToCreate": "GitHub PRの作成に失敗しました", "gitCliNotLoggedIn": "Gitが認証されていません。\"gh auth login\" を実行するかGitの認証情報を設定してから再試行してください。", - "gitCliNotInstalled": "Git CLIがインストールされていません。PRを作成するにはGitをインストールしてください。" + "gitCliNotInstalled": "Git CLIがインストールされていません。PRを作成するにはGitをインストールしてください。", + "targetBranchNotFound": "ターゲットブランチ '{{branch}}' がリモートに存在しません。プルリクエストを作成する前にブランチが存在することを確認してください。" }, "loginRequired": { "title": "プルリクエストを作成するにはサインインしてください", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index 2704a3f8..627ca454 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -119,7 +119,8 @@ "repoNotFoundOrNoAccess": "저장소를 찾을 수 없거나 액세스 권한이 없습니다. 저장소 액세스를 확인하고 인증되었는지 확인하세요.", "failedToCreate": "GitHub PR 생성에 실패했습니다", "gitCliNotLoggedIn": "Git이 인증되지 않았습니다. \"gh auth login\"을 실행하거나 Git 자격 증명을 설정한 후 다시 시도하세요.", - "gitCliNotInstalled": "Git CLI가 설치되어 있지 않습니다. PR을 생성하려면 Git을 설치하세요." + "gitCliNotInstalled": "Git CLI가 설치되어 있지 않습니다. PR을 생성하려면 Git을 설치하세요.", + "targetBranchNotFound": "대상 브랜치 '{{branch}}'이(가) 원격에 존재하지 않습니다. 풀 리퀘스트를 생성하기 전에 브랜치가 존재하는지 확인하세요." }, "loginRequired": { "title": "Pull Request를 만들려면 로그인하세요", diff --git a/shared/types.ts b/shared/types.ts index 930dcf9a..c19c9ae5 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -296,7 +296,7 @@ export type RebaseTaskAttemptRequest = { old_base_branch: string | null, new_bas export type GitOperationError = { "type": "merge_conflicts", message: string, op: ConflictOp, } | { "type": "rebase_in_progress" }; -export type CreatePrError = { "type": "github_cli_not_installed" } | { "type": "github_cli_not_logged_in" } | { "type": "git_cli_not_logged_in" } | { "type": "git_cli_not_installed" }; +export type CreatePrError = { "type": "github_cli_not_installed" } | { "type": "github_cli_not_logged_in" } | { "type": "git_cli_not_logged_in" } | { "type": "git_cli_not_installed" } | { "type": "target_branch_not_found", branch: string, }; export type CommitInfo = { sha: string, subject: string, };