Task attempt 92d72de4-1404-4eee-94c4-a679c302425c - Final changes

This commit is contained in:
Louis Knight-Webb
2025-06-20 16:56:57 +01:00
parent 0012f3ca09
commit 7955cbe890
3 changed files with 166 additions and 3 deletions

View File

@@ -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<String, TaskAttemptError> {
// 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<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
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())
}
}

View File

@@ -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<DeleteFileQuery>,
Extension(pool): Extension<SqlitePool>,
) -> Result<ResponseJson<ApiResponse<()>>, 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)]

View File

@@ -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<Set<string>>(new Set());
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(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<null> = 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 (
<div className="min-h-screen bg-background flex items-center justify-center">
@@ -439,10 +474,23 @@ export function TaskAttemptComparePage() {
<div className="space-y-6">
{diff.files.map((file, fileIndex) => (
<div key={fileIndex} className="border rounded-lg overflow-hidden">
<div className="bg-muted px-3 py-2 border-b">
<div className="bg-muted px-3 py-2 border-b flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground font-mono">
{file.path}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteFile(file.path)}
disabled={deletingFiles.has(file.path)}
className="text-red-600 hover:text-red-800 hover:bg-red-50 h-8 px-3 gap-1"
title={`Delete ${file.path}`}
>
<Trash2 className="h-4 w-4" />
<span className="text-xs">
{deletingFiles.has(file.path) ? "Deleting..." : "Delete"}
</span>
</Button>
</div>
<div className="max-h-[600px] overflow-y-auto">
{processFileChunks(file.chunks, fileIndex).map((section, sectionIndex) => {