diff options
| author | soryu <soryu@soryu.co> | 2026-01-26 18:45:36 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-26 18:45:36 +0000 |
| commit | c9f5688d2d582ca03f2482c38baaf6e8c4f7a8be (patch) | |
| tree | 2fc9b94169fcb80836cc42137dd950e0cafb1f0e | |
| parent | cb4f2fc40dbabb40de948512eee74c7e46264665 (diff) | |
| download | soryu-makima/task-task-07de5d7d-07de5d7d.tar.gz soryu-makima/task-task-07de5d7d-07de5d7d.zip | |
[WIP] Heartbeat checkpoint - 2026-01-26 18:45:36 UTCmakima/task-task-07de5d7d-07de5d7d
| -rw-r--r-- | makima/src/daemon/storage/mod.rs | 5 | ||||
| -rw-r--r-- | makima/src/daemon/storage/patch.rs | 303 | ||||
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 84 | ||||
| -rw-r--r-- | makima/src/daemon/ws/protocol.rs | 35 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 92 |
5 files changed, 518 insertions, 1 deletions
diff --git a/makima/src/daemon/storage/mod.rs b/makima/src/daemon/storage/mod.rs index cc5441a..e5457f7 100644 --- a/makima/src/daemon/storage/mod.rs +++ b/makima/src/daemon/storage/mod.rs @@ -5,4 +5,7 @@ mod patch; -pub use patch::{create_patch, apply_patch, PatchError}; +pub use patch::{ + apply_patch, create_export_patch, create_patch, get_head_sha, get_parent_sha, ExportPatchResult, + PatchError, +}; diff --git a/makima/src/daemon/storage/patch.rs b/makima/src/daemon/storage/patch.rs index 45624b5..0da4eda 100644 --- a/makima/src/daemon/storage/patch.rs +++ b/makima/src/daemon/storage/patch.rs @@ -141,6 +141,223 @@ pub async fn get_parent_sha(worktree_path: &Path) -> Result<String, PatchError> Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +/// Get the current HEAD commit SHA from a worktree. +pub async fn get_head_sha(worktree_path: &Path) -> Result<String, PatchError> { + let output = Command::new("git") + .current_dir(worktree_path) + .args(["rev-parse", "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to get HEAD SHA: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PatchError::GitCommand(format!( + "git rev-parse HEAD failed: {}", + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Result of creating an export patch. +#[derive(Debug, Clone)] +pub struct ExportPatchResult { + /// The uncompressed, human-readable patch content. + pub patch_content: String, + /// Number of files changed in the patch. + pub files_count: usize, + /// Number of lines added. + pub lines_added: usize, + /// Number of lines removed. + pub lines_removed: usize, + /// The base commit SHA that the patch is diffed against. + pub base_commit_sha: String, +} + +/// Create an uncompressed git diff patch for export. +/// +/// This creates a human-readable patch that can be applied manually or +/// shared as a file. Unlike `create_patch`, this version is not compressed +/// and is suitable for display or export. +/// +/// If `base_sha` is provided, the diff is between that commit and HEAD. +/// If `base_sha` is None, it attempts to find the merge-base with the default branch +/// or falls back to diffing uncommitted changes against HEAD. +pub async fn create_export_patch( + worktree_path: &Path, + base_sha: Option<&str>, +) -> Result<ExportPatchResult, PatchError> { + // Determine the base SHA to diff against + let resolved_base_sha = match base_sha { + Some(sha) => sha.to_string(), + None => { + // Try to find the merge-base with the default branch + // First, try to get the upstream tracking branch + let upstream_result = Command::new("git") + .current_dir(worktree_path) + .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + .output() + .await; + + let base = if let Ok(output) = upstream_result { + if output.status.success() { + let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Get merge-base with upstream + let merge_base = Command::new("git") + .current_dir(worktree_path) + .args(["merge-base", "HEAD", &upstream]) + .output() + .await; + + if let Ok(mb_output) = merge_base { + if mb_output.status.success() { + Some(String::from_utf8_lossy(&mb_output.stdout).trim().to_string()) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + // If we couldn't find upstream, try common default branches + let base = if base.is_none() { + let default_branches = ["origin/main", "origin/master", "main", "master"]; + let mut found_base = None; + + for branch in default_branches { + let merge_base = Command::new("git") + .current_dir(worktree_path) + .args(["merge-base", "HEAD", branch]) + .output() + .await; + + if let Ok(output) = merge_base { + if output.status.success() { + found_base = Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); + break; + } + } + } + found_base + } else { + base + }; + + // If still nothing, get the first commit or use HEAD~1 + base.unwrap_or_else(|| { + // This will be used, but if HEAD~1 doesn't exist (only one commit), + // git diff will handle it gracefully + "HEAD~1".to_string() + }) + } + }; + + // Get diff stats using --stat + let stat_output = Command::new("git") + .current_dir(worktree_path) + .args(["diff", "--stat", &resolved_base_sha, "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to run git diff --stat: {}", e)))?; + + // Parse the stat output to get line counts + let (lines_added, lines_removed) = if stat_output.status.success() { + parse_diff_stat(&String::from_utf8_lossy(&stat_output.stdout)) + } else { + (0, 0) + }; + + // Get the actual diff content + let diff_output = Command::new("git") + .current_dir(worktree_path) + .args(["diff", &resolved_base_sha, "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to run git diff: {}", e)))?; + + if !diff_output.status.success() { + let stderr = String::from_utf8_lossy(&diff_output.stderr); + return Err(PatchError::GitCommand(format!("git diff failed: {}", stderr))); + } + + let patch_content = String::from_utf8_lossy(&diff_output.stdout).to_string(); + + // Check for empty patch + if patch_content.trim().is_empty() { + return Err(PatchError::EmptyPatch); + } + + // Count files changed + let files_output = Command::new("git") + .current_dir(worktree_path) + .args(["diff", "--name-only", &resolved_base_sha, "HEAD"]) + .output() + .await + .map_err(|e| PatchError::GitCommand(format!("Failed to count files: {}", e)))?; + + let files_count = if files_output.status.success() { + String::from_utf8_lossy(&files_output.stdout) + .lines() + .filter(|l| !l.is_empty()) + .count() + } else { + 0 + }; + + Ok(ExportPatchResult { + patch_content, + files_count, + lines_added, + lines_removed, + base_commit_sha: resolved_base_sha, + }) +} + +/// Parse git diff --stat output to extract lines added and removed. +/// The last line typically looks like: " 3 files changed, 45 insertions(+), 12 deletions(-)" +fn parse_diff_stat(stat_output: &str) -> (usize, usize) { + let mut lines_added = 0; + let mut lines_removed = 0; + + // Look for the summary line at the end + for line in stat_output.lines().rev() { + let line = line.trim(); + if line.contains("changed") || line.contains("insertion") || line.contains("deletion") { + // Parse insertions + if let Some(idx) = line.find("insertion") { + let before = &line[..idx]; + if let Some(num_str) = before.split_whitespace().last() { + if let Ok(num) = num_str.parse::<usize>() { + lines_added = num; + } + } + } + // Parse deletions + if let Some(idx) = line.find("deletion") { + let before = &line[..idx]; + if let Some(num_str) = before.split(',').last() { + if let Some(num_str) = num_str.trim().split_whitespace().next() { + if let Ok(num) = num_str.parse::<usize>() { + lines_removed = num; + } + } + } + } + break; + } + } + + (lines_added, lines_removed) +} + /// Checkout a specific commit in the worktree. pub async fn checkout_commit(worktree_path: &Path, sha: &str) -> Result<(), PatchError> { let output = Command::new("git") @@ -290,4 +507,90 @@ mod tests { let result = create_patch(path, &head_sha).await; assert!(matches!(result, Err(PatchError::EmptyPatch))); } + + #[tokio::test] + async fn test_create_export_patch() { + let dir = setup_test_repo().await; + let path = dir.path(); + + // Get the initial commit SHA before making changes + let initial_sha = get_head_sha(path).await.unwrap(); + + // Make some changes and commit + fs::write(path.join("file.txt"), "modified content").unwrap(); + fs::write(path.join("new_file.txt"), "new file content").unwrap(); + Command::new("git") + .current_dir(path) + .args(["add", "."]) + .output() + .await + .unwrap(); + Command::new("git") + .current_dir(path) + .args(["commit", "-m", "changes for export"]) + .output() + .await + .unwrap(); + + // Create export patch with explicit base + let result = create_export_patch(path, Some(&initial_sha)).await.unwrap(); + + // Verify the result + assert!(!result.patch_content.is_empty()); + assert_eq!(result.files_count, 2); // file.txt and new_file.txt + assert!(result.lines_added > 0); + assert_eq!(result.base_commit_sha, initial_sha); + + // The patch should contain diff headers + assert!(result.patch_content.contains("diff --git")); + assert!(result.patch_content.contains("new_file.txt")); + } + + #[tokio::test] + async fn test_create_export_patch_no_base() { + let dir = setup_test_repo().await; + let path = dir.path(); + + // Make a second commit so we have something to diff + fs::write(path.join("file.txt"), "modified").unwrap(); + Command::new("git") + .current_dir(path) + .args(["add", "."]) + .output() + .await + .unwrap(); + Command::new("git") + .current_dir(path) + .args(["commit", "-m", "second commit"]) + .output() + .await + .unwrap(); + + // Create export patch without explicit base (will use HEAD~1) + let result = create_export_patch(path, None).await.unwrap(); + + // Verify the result + assert!(!result.patch_content.is_empty()); + assert_eq!(result.files_count, 1); + assert!(result.patch_content.contains("diff --git")); + } + + #[tokio::test] + async fn test_parse_diff_stat() { + // Test the parse_diff_stat function with various formats + let stat1 = " 3 files changed, 45 insertions(+), 12 deletions(-)"; + let (added, removed) = parse_diff_stat(stat1); + assert_eq!(added, 45); + assert_eq!(removed, 12); + + let stat2 = " 1 file changed, 10 insertions(+)"; + let (added, removed) = parse_diff_stat(stat2); + assert_eq!(added, 10); + assert_eq!(removed, 0); + + let stat3 = " 2 files changed, 5 deletions(-)"; + let (added, removed) = parse_diff_stat(stat3); + assert_eq!(added, 0); + assert_eq!(removed, 5); + } } diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 74a37bf..f933111 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -1987,6 +1987,10 @@ impl TaskManager { tracing::info!(source_dir = ?source_dir, "Inheriting git config"); self.handle_inherit_git_config(source_dir).await?; } + DaemonCommand::CreateExportPatch { task_id, base_sha } => { + tracing::info!(task_id = %task_id, base_sha = ?base_sha, "Creating export patch"); + self.handle_create_export_patch(task_id, base_sha).await?; + } DaemonCommand::RestartDaemon => { tracing::info!("Received restart command from server, initiating daemon restart..."); // Shutdown all running tasks gracefully @@ -2707,6 +2711,86 @@ impl TaskManager { Ok(()) } + /// Handle CreateExportPatch command. + /// + /// Creates an uncompressed, human-readable git patch for export. + async fn handle_create_export_patch( + &self, + task_id: Uuid, + base_sha: Option<String>, + ) -> Result<(), DaemonError> { + // Get task's worktree path + let worktree_result = self.get_task_worktree_path(task_id).await; + + let msg = match worktree_result { + Ok(worktree_path) => { + // Create the export patch + match storage::create_export_patch(&worktree_path, base_sha.as_deref()).await { + Ok(result) => { + tracing::info!( + task_id = %task_id, + files_count = result.files_count, + lines_added = result.lines_added, + lines_removed = result.lines_removed, + base_commit_sha = %result.base_commit_sha, + "Export patch created successfully" + ); + + DaemonMessage::ExportPatchCreated { + task_id, + success: true, + patch_content: Some(result.patch_content), + files_count: Some(result.files_count), + lines_added: Some(result.lines_added), + lines_removed: Some(result.lines_removed), + base_commit_sha: Some(result.base_commit_sha), + error: None, + } + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + error = %e, + "Failed to create export patch" + ); + + DaemonMessage::ExportPatchCreated { + task_id, + success: false, + patch_content: None, + files_count: None, + lines_added: None, + lines_removed: None, + base_commit_sha: None, + error: Some(e.to_string()), + } + } + } + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + error = %e, + "Failed to get worktree path for export patch" + ); + + DaemonMessage::ExportPatchCreated { + task_id, + success: false, + patch_content: None, + files_count: None, + lines_added: None, + lines_removed: None, + base_commit_sha: None, + error: Some(format!("Task not found or has no worktree: {}", e)), + } + } + }; + + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + /// Handle ReadRepoFile command. /// /// Reads a file from a repository on the daemon's filesystem and sends diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs index 2e7caef..ddf3a95 100644 --- a/makima/src/daemon/ws/protocol.rs +++ b/makima/src/daemon/ws/protocol.rs @@ -316,6 +316,30 @@ pub enum DaemonMessage { message: String, }, + /// Response to CreateExportPatch command. + ExportPatchCreated { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + /// The uncompressed, human-readable patch content. + #[serde(rename = "patchContent")] + patch_content: Option<String>, + /// Number of files changed. + #[serde(rename = "filesCount")] + files_count: Option<usize>, + /// Lines added. + #[serde(rename = "linesAdded")] + lines_added: Option<usize>, + /// Lines removed. + #[serde(rename = "linesRemoved")] + lines_removed: Option<usize>, + /// The base commit SHA that the patch is diffed against. + #[serde(rename = "baseCommitSha")] + base_commit_sha: Option<String>, + /// Error message if failed. + error: Option<String>, + }, + /// Response to InheritGitConfig command. GitConfigInherited { success: bool, @@ -646,6 +670,17 @@ pub enum DaemonCommand { delete_branch: bool, }, + /// Create an uncompressed git patch for export. + /// Returns a human-readable patch that can be applied manually or shared. + CreateExportPatch { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Optional base SHA to diff against. If not provided, will try to find + /// the merge-base with the default branch. + #[serde(rename = "baseSha")] + base_sha: Option<String>, + }, + /// Inherit git config (user.email, user.name) from a directory. /// This config will be applied to all future worktrees. InheritGitConfig { diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index f5a3c10..6934662 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -448,6 +448,29 @@ pub enum DaemonMessage { /// Error message if operation failed error: Option<String>, }, + /// Response to CreateExportPatch command + ExportPatchCreated { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + /// The uncompressed, human-readable patch content + #[serde(rename = "patchContent")] + patch_content: Option<String>, + /// Number of files changed + #[serde(rename = "filesCount")] + files_count: Option<usize>, + /// Lines added + #[serde(rename = "linesAdded")] + lines_added: Option<usize>, + /// Lines removed + #[serde(rename = "linesRemoved")] + lines_removed: Option<usize>, + /// The base commit SHA that the patch is diffed against + #[serde(rename = "baseCommitSha")] + base_commit_sha: Option<String>, + /// Error message if failed + error: Option<String>, + }, /// Response to MergeTaskToTarget command MergeToTargetResult { #[serde(rename = "taskId")] @@ -1783,6 +1806,75 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re ); } } + Ok(DaemonMessage::ExportPatchCreated { + task_id, + success, + patch_content, + files_count, + lines_added, + lines_removed, + base_commit_sha, + error, + }) => { + if success { + tracing::info!( + task_id = %task_id, + files_count = ?files_count, + lines_added = ?lines_added, + lines_removed = ?lines_removed, + base_commit_sha = ?base_commit_sha, + patch_len = patch_content.as_ref().map(|p| p.len()), + "Export patch created successfully" + ); + + // Broadcast as task output so UI can access the result + let output_text = format!( + "✓ Export patch created: {} files changed, +{} -{} lines (base: {})", + files_count.unwrap_or(0), + lines_added.unwrap_or(0), + lines_removed.unwrap_or(0), + base_commit_sha.as_deref().unwrap_or("unknown") + ); + state.broadcast_task_output(TaskOutputNotification { + task_id, + owner_id: Some(owner_id), + message_type: "export_patch".to_string(), + content: output_text, + tool_name: None, + tool_input: Some(serde_json::json!({ + "patchContent": patch_content, + "filesCount": files_count, + "linesAdded": lines_added, + "linesRemoved": lines_removed, + "baseCommitSha": base_commit_sha, + })), + is_error: None, + cost_usd: None, + duration_ms: None, + is_partial: false, + }); + } else { + tracing::warn!( + task_id = %task_id, + error = ?error, + "Failed to create export patch" + ); + + // Broadcast error + state.broadcast_task_output(TaskOutputNotification { + task_id, + owner_id: Some(owner_id), + message_type: "error".to_string(), + content: format!("✗ Export patch failed: {}", error.unwrap_or_else(|| "Unknown error".to_string())), + tool_name: None, + tool_input: None, + is_error: Some(true), + cost_usd: None, + duration_ms: None, + is_partial: false, + }); + } + } Err(e) => { tracing::warn!("Failed to parse daemon message: {}", e); } |
