From 036dd802d8677eb23275b5e1052cf16b89fc8884 Mon Sep 17 00:00:00 2001 From: Alex Netsch Date: Mon, 24 Nov 2025 15:52:05 +0000 Subject: [PATCH] Force push (vibe-kanban) (#1350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Excellent! All tasks completed successfully. Here's a summary of the changes: I've successfully added typed error handling to the push method following the same pattern as `CreatePrError`. Here's what was implemented: 1. **Created `PushError` enum** in `crates/server/src/routes/task_attempts.rs:608-613` - Single variant: `ForcePushRequired` - Uses proper derives for TypeScript generation 2. **Updated push handler signature** in `crates/server/src/routes/task_attempts.rs:596` - Changed return type to `Result>, ApiError>` 3. **Registered error type** in `crates/server/src/bin/generate_types.rs:140` - Added `PushError::decl()` to the type declarations 4. **Generated TypeScript type** in `shared/types.ts:301` - Type: `export type PushError = { "type": "force_push_required" }` 5. **Updated frontend API** in `frontend/src/lib/api.ts` - Imported `PushError` type - Changed `push` method to return `Result` - Uses `handleApiResponseAsResult` for proper error handling 6. **Updated `usePush` hook** in `frontend/src/hooks/usePush.ts` - Created custom `PushErrorWithData` class to properly pass error data - Updated signature to pass `errorData` to `onError` callback 7. **Updated component error handling** in `frontend/src/hooks/useGitOperations.ts:47-52` - Added handling for `force_push_required` error type - Displays user-friendly message: "Force push required. The remote branch has diverged." All checks pass successfully! The implementation is ready for future force push functionality. Cleanup script changes for task attempt 982f21cc-f0a3-455d-8b30-3a9911c2889f Excellent! All tasks completed successfully. Here's a summary of the force push implementation: I've successfully implemented a complete force push feature with user confirmation. Here's what was done: 1. **Updated Git CLI** (`crates/services/src/services/git/cli.rs:318`) - Added `force: bool` parameter to `push()` method - Uses `+refs/heads/{branch}` refspec when force is true 2. **Updated Git Service** (`crates/services/src/services/git.rs:1615`) - Added `force: bool` parameter to `push_to_github()` method - Passes force flag to Git CLI 3. **Added Force Push Endpoint** (`crates/server/src/routes/task_attempts.rs:608`) - New handler: `force_push_task_attempt_branch` - Calls `push_to_github()` with `force: true` - Regular push endpoint now passes `force: false` 4. **Added Route** (`crates/server/src/routes/task_attempts.rs:1581`) - Route: `POST /api/task-attempts/:id/push/force` 5. **API Client** (`frontend/src/lib/api.ts:561`) - Added `forcePush()` method - Returns `Result` 6. **Created useForcePush Hook** (`frontend/src/hooks/useForcePush.ts`) - React Query mutation for force push - Handles typed errors - Invalidates branch status on success 7. **Created ForcePushDialog** (`frontend/src/components/dialogs/git/ForcePushDialog.tsx`) - Destructive variant with warning icon - Clear warning about overwriting remote changes - Buttons: "Cancel" and "Force Push" 8. **Integrated Dialog Flow** (`frontend/src/hooks/useGitOperations.ts:58-85`) - When regular push fails with `force_push_required` error: - Shows ForcePushDialog to confirm - If user confirms, executes force push - Handles errors from force push operation 1. User attempts to push 2. If remote has diverged, backend returns `force_push_required` error 3. Frontend shows confirmation dialog explaining the consequences 4. User can cancel or confirm force push 5. If confirmed, force push is executed 6. Success/error feedback is shown All checks pass successfully! The feature is ready to use. Cleanup script changes for task attempt 982f21cc-f0a3-455d-8b30-3a9911c2889f Perfect! The fix is complete and all checks pass. Here's what was changed: **Problem:** The `push_task_attempt_branch` endpoint was using the `?` operator which converted `GitServiceError::GitCLI(GitCliError::PushRejected(_))` into a generic `ApiError`, preventing the frontend from receiving the typed `PushError::ForcePushRequired` error. **Solution:** Updated the endpoint in `crates/server/src/routes/task_attempts.rs:602-613` to use a `match` statement that: 1. Returns success on `Ok(_)` 2. Detects `GitServiceError::GitCLI(GitCliError::PushRejected(_))` and returns the typed error `ApiResponse::error_with_data(PushError::ForcePushRequired)` 3. Returns generic `ApiError::GitService(e)` for other errors Now when a push is rejected due to non-fast-forward, the backend will return: ```json { "success": false, "error_data": { "type": "force_push_required" } } ``` This will trigger the force push confirmation dialog in the frontend, allowing the user to choose whether to force push or cancel. Cleanup script changes for task attempt 982f21cc-f0a3-455d-8b30-3a9911c2889f Perfect! The fix is complete and all checks pass. Here's what was changed: **Problem:** The ForcePushDialog was not closing after the user clicked "Force Push" or "Cancel" because it only called `modal.resolve()` without calling `modal.hide()`. **Solution:** Added `modal.hide()` calls to both handlers in `frontend/src/components/dialogs/git/ForcePushDialog.tsx`: - **Line 24:** Added `modal.hide()` after `modal.resolve('confirmed')` in `handleConfirm` - **Line 29:** Added `modal.hide()` after `modal.resolve('canceled')` in `handleCancel` Now the dialog will properly close when the user clicks either button. This follows the correct NiceModal pattern where you need both: 1. `modal.resolve()` - to resolve the promise with a result 2. `modal.hide()` - to actually hide the dialog The force push feature should now work end-to-end: push rejection → confirmation dialog → force push → dialog closes. Perfect! All checks pass. Here's what was fixed: The force push dialog now follows the same pattern as all other async dialogs in the codebase. **Added:** - `attemptId` prop (required) to identify which task attempt to force push - `useForcePush` hook usage internally - Loading state with `forcePush.isPending` - Error state displayed in the dialog - Loader spinner and "Force Pushing..." button text during operation - Disabled buttons during operation - Dialog stays open until operation completes **Behavior:** - User clicks "Force Push" → button shows spinner and "Force Pushing..." - On success → dialog closes automatically - On error → error shown in dialog, stays open, user can retry or cancel **Changed:** - Removed the manual force push call after dialog confirmation - Now passes `attemptId` to the dialog: `ForcePushDialog.show({ attemptId })` - Dialog handles the entire force push operation internally 1. User attempts push 2. Push rejected with `force_push_required` error 3. Dialog appears with warning 4. User clicks "Force Push" 5. **Button shows spinner and "Force Pushing..."** ← New! 6. **Buttons disabled during operation** ← New! 7. On success: dialog closes 8. On error: error displayed in dialog, user can retry This matches the pattern used by CreatePRDialog, GhCliSetupDialog, and other async dialogs in the codebase. * Force push i18n (vibe-kanban 5519a7db) Run @scripts/check-i18n.sh until it passes. Make sure to check the script and set GITHUB_BASE_REF to vk/607c-add-pre-flight-c Force push i18n (vibe-kanban 5519a7db) Run @scripts/check-i18n.sh until it passes. Make sure to check the script and set GITHUB_BASE_REF to vk/607c-add-pre-flight-c * fix tests --- crates/server/src/bin/generate_types.rs | 1 + crates/server/src/routes/task_attempts.rs | 35 +++++- crates/services/src/services/git.rs | 3 +- crates/services/src/services/git/cli.rs | 7 +- crates/services/tests/git_ops_safety.rs | 4 +- .../dialogs/git/ForcePushDialog.tsx | 112 ++++++++++++++++++ frontend/src/hooks/useForcePush.ts | 45 +++++++ frontend/src/hooks/useGitOperations.ts | 28 ++++- frontend/src/hooks/usePush.ts | 29 ++++- frontend/src/i18n/locales/en/tasks.json | 9 ++ frontend/src/i18n/locales/es/tasks.json | 9 ++ frontend/src/i18n/locales/ja/tasks.json | 9 ++ frontend/src/i18n/locales/ko/tasks.json | 9 ++ frontend/src/lib/api.ts | 15 ++- shared/types.ts | 2 + 15 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/dialogs/git/ForcePushDialog.tsx create mode 100644 frontend/src/hooks/useForcePush.ts 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, };