diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 132b71f5..8fa35779 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -902,4 +902,77 @@ impl TaskAttempt { // No need to update database as we now get base_commit live from git Ok(new_base_commit) } + + /// Delete a file from the worktree and commit the change + pub async fn delete_file( + pool: &SqlitePool, + attempt_id: Uuid, + task_id: Uuid, + project_id: Uuid, + file_path: &str, + ) -> Result { + // Get the task attempt with validation + let attempt = sqlx::query_as!( + TaskAttempt, + r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.merge_commit, ta.executor, ta.stdout, ta.stderr, ta.created_at as "created_at!: DateTime", ta.updated_at as "updated_at!: DateTime" + FROM task_attempts ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, + attempt_id, + task_id, + project_id + ) + .fetch_optional(pool) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + // Open the worktree repository + let repo = Repository::open(&attempt.worktree_path)?; + + // Get the absolute path to the file within the worktree + let worktree_path = Path::new(&attempt.worktree_path); + let file_full_path = worktree_path.join(file_path); + + // Check if file exists and delete it + if file_full_path.exists() { + std::fs::remove_file(&file_full_path) + .map_err(|e| TaskAttemptError::Git(GitError::from_str(&format!( + "Failed to delete file {}: {}", + file_path, + e + ))))?; + + debug!("Deleted file: {}", file_path); + } else { + info!("File {} does not exist, skipping deletion", file_path); + } + + // Stage the deletion + let mut index = repo.index()?; + index.remove_path(Path::new(file_path))?; + index.write()?; + + // Create a commit for the file deletion + let signature = repo.signature()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + + // Get the current HEAD commit + let head = repo.head()?; + let parent_commit = head.peel_to_commit()?; + + let commit_message = format!("Delete file: {}", file_path); + let commit_id = repo.commit( + Some("HEAD"), + &signature, + &signature, + &commit_message, + &tree, + &[&parent_commit], + )?; + + info!("File {} deleted and committed: {}", file_path, commit_id); + + Ok(commit_id.to_string()) + } } diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index 060c2511..e0e95f31 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Extension, Path}, + extract::{Extension, Path, Query}, http::StatusCode, response::Json as ResponseJson, routing::get, @@ -554,6 +554,44 @@ pub async fn rebase_task_attempt( } } +#[derive(serde::Deserialize)] +pub struct DeleteFileQuery { + file_path: String, +} + +#[axum::debug_handler] +pub async fn delete_task_attempt_file( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Query(query): Query, + Extension(pool): Extension, +) -> Result>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(true) => {} + } + + match TaskAttempt::delete_file(&pool, attempt_id, task_id, project_id, &query.file_path).await { + Ok(_commit_id) => Ok(ResponseJson(ApiResponse { + success: true, + data: None, + message: Some(format!("File '{}' deleted successfully", query.file_path)), + })), + Err(e) => { + tracing::error!("Failed to delete file '{}' from task attempt {}: {}", query.file_path, attempt_id, e); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(e.to_string()), + })) + } + } +} + pub fn tasks_router() -> Router { use axum::routing::post; @@ -598,6 +636,10 @@ pub fn tasks_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/open-editor", post(open_task_attempt_in_editor), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/delete-file", + post(delete_task_attempt_file), + ) } #[cfg(test)] diff --git a/frontend/src/pages/task-attempt-compare.tsx b/frontend/src/pages/task-attempt-compare.tsx index df75ecc6..05b1c4d2 100644 --- a/frontend/src/pages/task-attempt-compare.tsx +++ b/frontend/src/pages/task-attempt-compare.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, FileText, ChevronDown, ChevronUp, RefreshCw, GitBranch } from "lucide-react"; +import { ArrowLeft, FileText, ChevronDown, ChevronUp, RefreshCw, GitBranch, Trash2 } from "lucide-react"; import { makeRequest } from "@/lib/api"; import type { WorktreeDiff, DiffChunkType, DiffChunk, BranchStatus } from "shared/types"; @@ -30,6 +30,7 @@ export function TaskAttemptComparePage() { const [mergeSuccess, setMergeSuccess] = useState(false); const [rebaseSuccess, setRebaseSuccess] = useState(false); const [expandedSections, setExpandedSections] = useState>(new Set()); + const [deletingFiles, setDeletingFiles] = useState>(new Set()); useEffect(() => { if (projectId && taskId && attemptId) { @@ -319,6 +320,40 @@ export function TaskAttemptComparePage() { }); }; + const handleDeleteFile = async (filePath: string) => { + if (!projectId || !taskId || !attemptId) return; + + try { + setDeletingFiles(prev => new Set(prev).add(filePath)); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/delete-file?file_path=${encodeURIComponent(filePath)}`, + { + method: 'POST', + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success) { + // Refresh the diff to show updated state + fetchDiff(); + } else { + setError(result.message || "Failed to delete file"); + } + } else { + setError("Failed to delete file"); + } + } catch (err) { + setError("Failed to delete file"); + } finally { + setDeletingFiles(prev => { + const newSet = new Set(prev); + newSet.delete(filePath); + return newSet; + }); + } + }; + if (loading || branchStatusLoading) { return (
@@ -439,10 +474,23 @@ export function TaskAttemptComparePage() {
{diff.files.map((file, fileIndex) => (
-
+

{file.path}

+
{processFileChunks(file.chunks, fileIndex).map((section, sectionIndex) => {