diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 276b61c2..b6f43eca 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -136,6 +136,7 @@ fn generate_types_content() -> String { server::routes::task_attempts::gh_cli_setup::GhCliSetupError::decl(), server::routes::task_attempts::RebaseTaskAttemptRequest::decl(), server::routes::task_attempts::GitOperationError::decl(), + server::routes::task_attempts::PushError::decl(), server::routes::task_attempts::CreatePrError::decl(), server::routes::task_attempts::CommitInfo::decl(), server::routes::task_attempts::BranchStatus::decl(), diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 946179db..ea4ab99c 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -593,7 +593,28 @@ pub async fn merge_task_attempt( pub async fn push_task_attempt_branch( Extension(task_attempt): Extension, State(deployment): State, -) -> Result>, ApiError> { +) -> Result>, ApiError> { + let github_service = GitHubService::new()?; + github_service.check_token().await?; + + let ws_path = ensure_worktree_path(&deployment, &task_attempt).await?; + + match deployment + .git() + .push_to_github(&ws_path, &task_attempt.branch, false) + { + Ok(_) => Ok(ResponseJson(ApiResponse::success(()))), + Err(GitServiceError::GitCLI(GitCliError::PushRejected(_))) => Ok(ResponseJson( + ApiResponse::error_with_data(PushError::ForcePushRequired), + )), + Err(e) => Err(ApiError::GitService(e)), + } +} + +pub async fn force_push_task_attempt_branch( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { let github_service = GitHubService::new()?; github_service.check_token().await?; @@ -601,10 +622,17 @@ pub async fn push_task_attempt_branch( deployment .git() - .push_to_github(&ws_path, &task_attempt.branch)?; + .push_to_github(&ws_path, &task_attempt.branch, true)?; Ok(ResponseJson(ApiResponse::success(()))) } +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type", rename_all = "snake_case")] +pub enum PushError { + ForcePushRequired, +} + #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] @@ -675,7 +703,7 @@ pub async fn create_github_pr( // Push the branch to GitHub first if let Err(e) = deployment .git() - .push_to_github(&workspace_path, &task_attempt.branch) + .push_to_github(&workspace_path, &task_attempt.branch, false) { tracing::error!("Failed to push branch to GitHub: {}", e); match e { @@ -1556,6 +1584,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/diff/ws", get(stream_task_attempt_diff_ws)) .route("/merge", post(merge_task_attempt)) .route("/push", post(push_task_attempt_branch)) + .route("/push/force", post(force_push_task_attempt_branch)) .route("/rebase", post(rebase_task_attempt)) .route("/conflicts/abort", post(abort_conflicts_task_attempt)) .route("/pr", post(create_github_pr)) diff --git a/crates/services/src/services/git.rs b/crates/services/src/services/git.rs index 206b0ea9..d598f1e9 100644 --- a/crates/services/src/services/git.rs +++ b/crates/services/src/services/git.rs @@ -1613,6 +1613,7 @@ impl GitService { &self, worktree_path: &Path, branch_name: &str, + force: bool, ) -> Result<(), GitServiceError> { let repo = Repository::open(worktree_path)?; self.check_worktree_clean(&repo)?; @@ -1625,7 +1626,7 @@ impl GitService { .url() .ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?; let git_cli = GitCli::new(); - if let Err(e) = git_cli.push(worktree_path, remote_url, branch_name) { + if let Err(e) = git_cli.push(worktree_path, remote_url, branch_name, force) { tracing::error!("Push to GitHub failed: {}", e); return Err(e.into()); } diff --git a/crates/services/src/services/git/cli.rs b/crates/services/src/services/git/cli.rs index be189d46..ec3c609f 100644 --- a/crates/services/src/services/git/cli.rs +++ b/crates/services/src/services/git/cli.rs @@ -320,8 +320,13 @@ impl GitCli { repo_path: &Path, remote_url: &str, branch: &str, + force: bool, ) -> Result<(), GitCliError> { - let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); + let refspec = if force { + format!("+refs/heads/{branch}:refs/heads/{branch}") + } else { + format!("refs/heads/{branch}:refs/heads/{branch}") + }; let envs = vec![(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0"))]; let args = [ diff --git a/crates/services/tests/git_ops_safety.rs b/crates/services/tests/git_ops_safety.rs index c112a63c..bb072c9f 100644 --- a/crates/services/tests/git_ops_safety.rs +++ b/crates/services/tests/git_ops_safety.rs @@ -279,7 +279,7 @@ fn push_reports_non_fast_forward() { let remote_url_string = remote.url().expect("origin url").to_string(); let git_cli = GitCli::new(); - let result = git_cli.push(&local_path, &remote_url_string, "main"); + let result = git_cli.push(&local_path, &remote_url_string, "main", false); match result { Err(GitCliError::PushRejected(msg)) => { let lower = msg.to_ascii_lowercase(); @@ -377,7 +377,7 @@ fn push_and_fetch_roundtrip_updates_tracking_branch() { let git_cli = GitCli::new(); git_cli - .push(&producer_path, &remote_url_string, "main") + .push(&producer_path, &remote_url_string, "main", false) .expect("push succeeded"); let new_oid = producer_repo diff --git a/frontend/src/components/dialogs/git/ForcePushDialog.tsx b/frontend/src/components/dialogs/git/ForcePushDialog.tsx new file mode 100644 index 00000000..772b389e --- /dev/null +++ b/frontend/src/components/dialogs/git/ForcePushDialog.tsx @@ -0,0 +1,112 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import { defineModal } from '@/lib/modals'; +import { useForcePush } from '@/hooks/useForcePush'; +import { useState } from 'react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useTranslation } from 'react-i18next'; + +export interface ForcePushDialogProps { + attemptId: string; + branchName?: string; +} + +const ForcePushDialogImpl = NiceModal.create((props) => { + const modal = useModal(); + const { attemptId, branchName } = props; + const [error, setError] = useState(null); + const { t } = useTranslation(['tasks', 'common']); + const branchLabel = branchName ? ` "${branchName}"` : ''; + + const forcePush = useForcePush( + attemptId, + () => { + // Success - close dialog + modal.resolve('success'); + modal.hide(); + }, + (err: unknown) => { + // Error - show in dialog and keep open + const message = + err && typeof err === 'object' && 'message' in err + ? String(err.message) + : t('tasks:git.forcePushDialog.error'); + setError(message); + } + ); + + const handleConfirm = async () => { + setError(null); + try { + await forcePush.mutateAsync(); + } catch { + // Error already handled by onError callback + } + }; + + const handleCancel = () => { + modal.resolve('canceled'); + modal.hide(); + }; + + const isProcessing = forcePush.isPending; + + return ( + + + +
+ + {t('tasks:git.forcePushDialog.title')} +
+ +

{t('tasks:git.forcePushDialog.description', { branchLabel })}

+

+ {t('tasks:git.forcePushDialog.warning')} +

+

+ {t('tasks:git.forcePushDialog.note')} +

+
+
+ {error && ( + + {error} + + )} + + + + +
+
+ ); +}); + +export const ForcePushDialog = defineModal( + ForcePushDialogImpl +); diff --git a/frontend/src/hooks/useForcePush.ts b/frontend/src/hooks/useForcePush.ts new file mode 100644 index 00000000..6fb3dbce --- /dev/null +++ b/frontend/src/hooks/useForcePush.ts @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { attemptsApi } from '@/lib/api'; +import type { PushError } from 'shared/types'; + +class ForcePushErrorWithData extends Error { + constructor( + message: string, + public errorData?: PushError + ) { + super(message); + this.name = 'ForcePushErrorWithData'; + } +} + +export function useForcePush( + attemptId?: string, + onSuccess?: () => void, + onError?: (err: unknown, errorData?: PushError) => void +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!attemptId) return; + const result = await attemptsApi.forcePush(attemptId); + if (!result.success) { + throw new ForcePushErrorWithData( + result.message || 'Force push failed', + result.error + ); + } + }, + onSuccess: () => { + // A force push affects remote status; invalidate the same branchStatus + queryClient.invalidateQueries({ queryKey: ['branchStatus', attemptId] }); + onSuccess?.(); + }, + onError: (err) => { + console.error('Failed to force push:', err); + const errorData = + err instanceof ForcePushErrorWithData ? err.errorData : undefined; + onError?.(err, errorData); + }, + }); +} diff --git a/frontend/src/hooks/useGitOperations.ts b/frontend/src/hooks/useGitOperations.ts index a6e6d23a..196b2e21 100644 --- a/frontend/src/hooks/useGitOperations.ts +++ b/frontend/src/hooks/useGitOperations.ts @@ -1,10 +1,12 @@ import { useRebase } from './useRebase'; import { useMerge } from './useMerge'; import { usePush } from './usePush'; +import { useForcePush } from './useForcePush'; import { useChangeTargetBranch } from './useChangeTargetBranch'; import { useGitOperationsError } from '@/contexts/GitOperationsContext'; import { Result } from '@/lib/api'; import type { GitOperationError } from 'shared/types'; +import { ForcePushDialog } from '@/components/dialogs/git/ForcePushDialog'; export function useGitOperations( attemptId: string | undefined, @@ -41,10 +43,31 @@ export function useGitOperations( } ); - const push = usePush( + const forcePush = useForcePush( attemptId, () => setError(null), (err: unknown) => { + const message = + err && typeof err === 'object' && 'message' in err + ? String(err.message) + : 'Failed to force push'; + setError(message); + } + ); + + const push = usePush( + attemptId, + () => setError(null), + async (err: unknown, errorData) => { + // Handle typed push errors + if (errorData?.type === 'force_push_required') { + // Show confirmation dialog - dialog handles the force push internally + if (attemptId) { + await ForcePushDialog.show({ attemptId }); + } + return; + } + const message = err && typeof err === 'object' && 'message' in err ? String(err.message) @@ -70,6 +93,7 @@ export function useGitOperations( rebase.isPending || merge.isPending || push.isPending || + forcePush.isPending || changeTargetBranch.isPending; return { @@ -77,6 +101,7 @@ export function useGitOperations( rebase: rebase.mutateAsync, merge: merge.mutateAsync, push: push.mutateAsync, + forcePush: forcePush.mutateAsync, changeTargetBranch: changeTargetBranch.mutateAsync, }, isAnyLoading, @@ -84,6 +109,7 @@ export function useGitOperations( rebasePending: rebase.isPending, mergePending: merge.isPending, pushPending: push.isPending, + forcePushPending: forcePush.isPending, changeTargetBranchPending: changeTargetBranch.isPending, }, }; diff --git a/frontend/src/hooks/usePush.ts b/frontend/src/hooks/usePush.ts index d7e80db4..eea88e85 100644 --- a/frontend/src/hooks/usePush.ts +++ b/frontend/src/hooks/usePush.ts @@ -1,17 +1,34 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { attemptsApi } from '@/lib/api'; +import type { PushError } from 'shared/types'; + +class PushErrorWithData extends Error { + constructor( + message: string, + public errorData?: PushError + ) { + super(message); + this.name = 'PushErrorWithData'; + } +} export function usePush( attemptId?: string, onSuccess?: () => void, - onError?: (err: unknown) => void + onError?: (err: unknown, errorData?: PushError) => void ) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => { - if (!attemptId) return Promise.resolve(); - return attemptsApi.push(attemptId); + mutationFn: async () => { + if (!attemptId) return; + const result = await attemptsApi.push(attemptId); + if (!result.success) { + throw new PushErrorWithData( + result.message || 'Push failed', + result.error + ); + } }, onSuccess: () => { // A push only affects remote status; invalidate the same branchStatus @@ -20,7 +37,9 @@ export function usePush( }, onError: (err) => { console.error('Failed to push:', err); - onError?.(err); + const errorData = + err instanceof PushErrorWithData ? err.errorData : undefined; + onError?.(err, errorData); }, }); } diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index b6e69604..5092fecc 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -197,6 +197,13 @@ "branch": { "current": "current" }, + "forcePushDialog": { + "title": "Force Push Required", + "description": "The remote branch{{branchLabel}} has diverged from your local branch. A regular push was rejected.", + "warning": "Force pushing will overwrite the remote changes with your local changes. This action cannot be undone.", + "note": "Only proceed if you're certain you want to replace the remote branch history.", + "error": "Failed to force push" + }, "status": { "commits_one": "commit", "commits_other": "commits", @@ -214,6 +221,8 @@ "pushed": "Pushed!", "pushing": "Pushing...", "push": "Push", + "forcePush": "Force Push", + "forcePushing": "Force Pushing...", "creating": "Creating...", "createPr": "Create PR" }, diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 498e90b1..edb46492 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -186,6 +186,13 @@ "branch": { "current": "actual" }, + "forcePushDialog": { + "title": "Se requiere push forzado", + "description": "La rama remota{{branchLabel}} se ha desviado de tu rama local. Se rechazó un push normal.", + "warning": "El push forzado sobrescribirá los cambios remotos con tus cambios locales. Esta acción no se puede deshacer.", + "note": "Solo continúa si estás seguro de que deseas reemplazar el historial remoto de la rama.", + "error": "No se pudo hacer el push forzado" + }, "errors": { "changeTargetBranch": "Error al cambiar rama de destino", "mergeChanges": "Error al fusionar cambios", @@ -210,6 +217,8 @@ }, "states": { "createPr": "Crear PR", + "forcePush": "Push forzado", + "forcePushing": "Push forzado en curso...", "creating": "Creando...", "merge": "Fusionar", "merged": "¡Fusionado!", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 163c32fd..6c13fbfd 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -186,6 +186,13 @@ "branch": { "current": "現在" }, + "forcePushDialog": { + "title": "強制プッシュが必要です", + "description": "リモートブランチ{{branchLabel}}がローカルブランチと乖離しています。通常のプッシュは拒否されました。", + "warning": "強制プッシュはリモートの変更をローカルの変更で上書きします。この操作は元に戻せません。", + "note": "リモートのブランチ履歴を置き換えてもよいと確信できる場合のみ続行してください。", + "error": "強制プッシュに失敗しました" + }, "errors": { "changeTargetBranch": "ターゲットブランチの変更に失敗しました", "mergeChanges": "変更のマージに失敗しました", @@ -217,6 +224,8 @@ "push": "プッシュ", "pushed": "プッシュ完了!", "pushing": "プッシュ中...", + "forcePush": "強制プッシュ", + "forcePushing": "強制プッシュ中...", "rebase": "リベース", "rebasing": "リベース中..." }, diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index 627ca454..35f0639e 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -186,6 +186,13 @@ "branch": { "current": "현재" }, + "forcePushDialog": { + "title": "강제 푸시가 필요합니다", + "description": "원격 브랜치{{branchLabel}}가 로컬 브랜치와 분기되었습니다. 일반 푸시가 거부되었습니다.", + "warning": "강제 푸시는 로컬 변경 사항으로 원격 변경을 덮어씁니다. 이 동작은 되돌릴 수 없습니다.", + "note": "원격 브랜치 기록을 대체해도 확실한 경우에만 계속하세요.", + "error": "강제 푸시에 실패했습니다" + }, "errors": { "changeTargetBranch": "대상 브랜치를 변경하지 못했습니다", "mergeChanges": "변경사항을 병합하지 못했습니다", @@ -217,6 +224,8 @@ "push": "푸시", "pushed": "푸시됨!", "pushing": "푸시 중...", + "forcePush": "강제 푸시", + "forcePushing": "강제 푸시 중...", "rebase": "리베이스", "rebasing": "리베이스 중..." }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c1b75497..603625b4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -71,6 +71,7 @@ import { OpenEditorResponse, OpenEditorRequest, CreatePrError, + PushError, } from 'shared/types'; // Re-export types for convenience @@ -550,11 +551,21 @@ export const attemptsApi = { return handleApiResponse(response); }, - push: async (attemptId: string): Promise => { + push: async (attemptId: string): Promise> => { const response = await makeRequest(`/api/task-attempts/${attemptId}/push`, { method: 'POST', }); - return handleApiResponse(response); + return handleApiResponseAsResult(response); + }, + + forcePush: async (attemptId: string): Promise> => { + const response = await makeRequest( + `/api/task-attempts/${attemptId}/push/force`, + { + method: 'POST', + } + ); + return handleApiResponseAsResult(response); }, rebase: async ( diff --git a/shared/types.ts b/shared/types.ts index c19c9ae5..16bf7fa4 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -296,6 +296,8 @@ 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 PushError = { "type": "force_push_required" }; + 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, };