diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8d9fead2..0182fda6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -28,6 +28,7 @@ ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } dirs = "5.0" git2 = "0.18" async-trait = "0.1" +dissimilar = "1.0" [build-dependencies] ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 5c292ee3..d113338b 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -87,6 +87,34 @@ pub struct UpdateTaskAttempt { pub merge_commit: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum DiffChunkType { + Equal, + Insert, + Delete, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct DiffChunk { + pub chunk_type: DiffChunkType, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct FileDiff { + pub path: String, + pub chunks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct WorktreeDiff { + pub files: Vec, +} + impl TaskAttempt { pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( @@ -251,4 +279,110 @@ impl TaskAttempt { Ok(()) } + + /// Get the git diff between the base commit and the current worktree state + pub async fn get_diff( + pool: &PgPool, + attempt_id: Uuid, + task_id: Uuid, + project_id: Uuid, + ) -> Result { + // Get the task attempt with validation + let attempt = sqlx::query_as!( + TaskAttempt, + r#"SELECT ta.id, ta.task_id, ta.worktree_path, ta.base_commit, ta.merge_commit, ta.executor, ta.stdout, ta.stderr, ta.created_at, ta.updated_at + 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)?; + + // Get the project to access the main repository + let _task = Task::find_by_id(pool, task_id) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + let _project = Project::find_by_id(pool, project_id) + .await? + .ok_or(TaskAttemptError::ProjectNotFound)?; + + // Open the worktree repository + let worktree_repo = Repository::open(&attempt.worktree_path)?; + + // Get the base commit + let base_commit_str = attempt + .base_commit + .ok_or_else(|| TaskAttemptError::Git(GitError::from_str("No base commit found")))?; + + let base_oid = + git2::Oid::from_str(&base_commit_str).map_err(|e| TaskAttemptError::Git(e))?; + + let base_commit = worktree_repo.find_commit(base_oid)?; + let base_tree = base_commit.tree()?; + + // Get status of all files in the worktree + let statuses = worktree_repo.statuses(None)?; + let mut files = Vec::new(); + + for status_entry in statuses.iter() { + if let Some(path_str) = status_entry.path() { + let path = std::path::Path::new(path_str); + let full_path = std::path::Path::new(&attempt.worktree_path).join(path); + + // Get old content from base commit + let old_content = match base_tree.get_path(path) { + Ok(entry) => match entry.to_object(&worktree_repo) { + Ok(obj) => { + if let Some(blob) = obj.as_blob() { + String::from_utf8_lossy(blob.content()).to_string() + } else { + String::new() + } + } + Err(_) => String::new(), + }, + Err(_) => String::new(), // File didn't exist in base commit + }; + + // Get new content from working directory + let new_content = std::fs::read_to_string(&full_path).unwrap_or_default(); + + // Generate diff chunks using dissimilar + if old_content != new_content { + let chunks = dissimilar::diff(&old_content, &new_content); + let mut diff_chunks = Vec::new(); + + for chunk in chunks { + let diff_chunk = match chunk { + dissimilar::Chunk::Equal(text) => DiffChunk { + chunk_type: DiffChunkType::Equal, + content: text.to_string(), + }, + dissimilar::Chunk::Delete(text) => DiffChunk { + chunk_type: DiffChunkType::Delete, + content: text.to_string(), + }, + dissimilar::Chunk::Insert(text) => DiffChunk { + chunk_type: DiffChunkType::Insert, + content: text.to_string(), + }, + }; + diff_chunks.push(diff_chunk); + } + + files.push(FileDiff { + path: path_str.to_string(), + chunks: diff_chunks, + }); + } + } + } + + Ok(WorktreeDiff { files }) + } } diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index 3addd993..f9eab841 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -12,7 +12,7 @@ use crate::auth::AuthUser; use crate::models::{ project::Project, task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask}, - task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptStatus}, + task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, WorktreeDiff}, task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity}, ApiResponse, }; @@ -376,6 +376,24 @@ pub async fn stop_task_attempt( })) } +pub async fn get_task_attempt_diff( + _auth: AuthUser, + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension, +) -> Result>, StatusCode> { + match TaskAttempt::get_diff(&pool, attempt_id, task_id, project_id).await { + Ok(diff) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(diff), + message: None, + })), + Err(e) => { + tracing::error!("Failed to get diff for task attempt {}: {}", attempt_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub fn tasks_router() -> Router { use axum::routing::{delete, post, put}; @@ -400,6 +418,10 @@ pub fn tasks_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/stop", post(stop_task_attempt), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/diff", + get(get_task_attempt_diff), + ) } #[cfg(test)] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff8715bd..5d37d3af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { HomePage } from '@/pages/home' import { Projects } from '@/pages/projects' import { ProjectTasks } from '@/pages/project-tasks' import { TaskDetailsPage } from '@/pages/task-details' +import { TaskAttemptComparePage } from '@/pages/task-attempt-compare' import { Users } from '@/pages/users' import { AuthProvider, useAuth } from '@/contexts/auth-context' @@ -44,6 +45,7 @@ function AppContent() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/task-attempt-compare.tsx b/frontend/src/pages/task-attempt-compare.tsx new file mode 100644 index 00000000..3aa6cd51 --- /dev/null +++ b/frontend/src/pages/task-attempt-compare.tsx @@ -0,0 +1,174 @@ +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 } from "lucide-react"; +import { makeAuthenticatedRequest } from "@/lib/auth"; +import type { WorktreeDiff, DiffChunkType } from "shared/types"; + +interface ApiResponse { + success: boolean; + data: T | null; + message: string | null; +} + +export function TaskAttemptComparePage() { + const { projectId, taskId, attemptId } = useParams<{ + projectId: string; + taskId: string; + attemptId: string; + }>(); + const navigate = useNavigate(); + + const [diff, setDiff] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (projectId && taskId && attemptId) { + fetchDiff(); + } + }, [projectId, taskId, attemptId]); + + const fetchDiff = async () => { + if (!projectId || !taskId || !attemptId) return; + + try { + setLoading(true); + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/diff` + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setDiff(result.data); + } else { + setError("Failed to load diff"); + } + } else { + setError("Failed to load diff"); + } + } catch (err) { + setError("Failed to load diff"); + } finally { + setLoading(false); + } + }; + + const handleBackClick = () => { + navigate(`/projects/${projectId}/tasks/${taskId}`); + }; + + const getChunkClassName = (chunkType: DiffChunkType) => { + const baseClass = "font-mono text-sm whitespace-pre px-3 py-1"; + + switch (chunkType) { + case 'Insert': + return `${baseClass} bg-green-50 text-green-800 border-l-2 border-green-400`; + case 'Delete': + return `${baseClass} bg-red-50 text-red-800 border-l-2 border-red-400`; + case 'Equal': + default: + return `${baseClass} text-gray-700`; + } + }; + + const getChunkPrefix = (chunkType: DiffChunkType) => { + switch (chunkType) { + case 'Insert': + return '+'; + case 'Delete': + return '-'; + case 'Equal': + default: + return ' '; + } + }; + + if (loading) { + return ( +
+
+
+

Loading diff...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+ +

+ + Compare Changes +

+
+
+ + + + + Diff: Base Commit vs. Current Worktree + +

+ Shows changes made in the task attempt worktree compared to the base commit +

+
+ + {!diff || diff.files.length === 0 ? ( +
+ +

No changes detected

+

The worktree is identical to the base commit

+
+ ) : ( +
+ {diff.files.map((file, fileIndex) => ( +
+
+

+ {file.path} +

+
+
+ {file.chunks.map((chunk, chunkIndex) => + chunk.content.split('\n').map((line, lineIndex) => ( +
+ {getChunkPrefix(chunk.chunk_type)}{line} +
+ )) + )} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/task-details.tsx b/frontend/src/pages/task-details.tsx index dcb6e370..6ff959e4 100644 --- a/frontend/src/pages/task-details.tsx +++ b/frontend/src/pages/task-details.tsx @@ -13,7 +13,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, FileText } from "lucide-react"; import { makeAuthenticatedRequest } from "@/lib/auth"; import type { TaskStatus, @@ -659,6 +659,17 @@ export function TaskDetailsPage() { Actions
+ {selectedAttempt && ( + + )} {isAttemptRunning && (