summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-26 18:45:36 +0000
committersoryu <soryu@soryu.co>2026-01-26 18:45:36 +0000
commitc9f5688d2d582ca03f2482c38baaf6e8c4f7a8be (patch)
tree2fc9b94169fcb80836cc42137dd950e0cafb1f0e
parentcb4f2fc40dbabb40de948512eee74c7e46264665 (diff)
downloadsoryu-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.rs5
-rw-r--r--makima/src/daemon/storage/patch.rs303
-rw-r--r--makima/src/daemon/task/manager.rs84
-rw-r--r--makima/src/daemon/ws/protocol.rs35
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs92
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);
}