diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c8783fa..1d8c023 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -96,7 +96,10 @@ "Bash(identify src-tauri/icons/GitCat.ico)", "Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")", "Bash(python3 *)", - "Bash(xxd -l 16 src-tauri/icons/GitCat.ico)" + "Bash(xxd -l 16 src-tauri/icons/GitCat.ico)", + "Bash(pkg-config --exists openssl)", + "Bash(sudo apt-get install -y libssl-dev pkg-config)", + "Bash(dpkg -L libssl3t64)" ] } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 29fa72a..6d881ea 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -789,63 +789,75 @@ fn validate_tag_ref_name(name: &str) -> Result { } #[tauri::command] -pub fn stage_files(path: String, files: Vec) -> Result { - let repo = resolve_repo(&path)?; - validate_files(&files)?; +pub async fn stage_files(path: String, files: Vec) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + validate_files(&files)?; - if !files.is_empty() { - // A file we've detected as an unstaged rename (see `detect_worktree_renames`) has - // to be staged with both its old and new path so `git add` records it as a rename - // instead of leaving the old path's deletion unstaged. - let current_status = status_for_repo(&repo)?; - let mut add_paths: Vec = Vec::new(); - for file in &files { - match find_status(¤t_status.files, file) { - Some(entry) => { - if let Some(old_path) = &entry.old_path { - add_paths.push(old_path.clone()); + if !files.is_empty() { + // A file we've detected as an unstaged rename (see `detect_worktree_renames`) has + // to be staged with both its old and new path so `git add` records it as a rename + // instead of leaving the old path's deletion unstaged. + let current_status = status_for_repo(&repo)?; + let mut add_paths: Vec = Vec::new(); + for file in &files { + match find_status(¤t_status.files, file) { + Some(entry) => { + if let Some(old_path) = &entry.old_path { + add_paths.push(old_path.clone()); + } + add_paths.push(entry.path.clone()); } - add_paths.push(entry.path.clone()); + None => add_paths.push(file.clone()), } - None => add_paths.push(file.clone()), } + run_git_with_paths(&repo, &["add"], &add_paths)?; } - run_git_with_paths(&repo, &["add"], &add_paths)?; - } - status_for_repo(&repo) + status_for_repo(&repo) + }) + .await + .map_err(|err| format!("Could not stage files: {err}"))? } #[tauri::command] -pub fn unstage_files(path: String, files: Vec) -> Result { - let repo = resolve_repo(&path)?; - validate_files(&files)?; +pub async fn unstage_files(path: String, files: Vec) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + validate_files(&files)?; + + if !files.is_empty() { + let current_status = status_for_repo(&repo)?; + unstage_selected_files(&repo, ¤t_status.files, &files)?; + } + + status_for_repo(&repo) + }) + .await + .map_err(|err| format!("Could not unstage files: {err}"))? +} + +#[tauri::command] +pub async fn restore_files(path: String, files: Vec, staged: bool) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + validate_files(&files)?; + + if files.is_empty() { + return status_for_repo(&repo); + } - if !files.is_empty() { let current_status = status_for_repo(&repo)?; - unstage_selected_files(&repo, ¤t_status.files, &files)?; - } + if staged { + restore_staged_files(&repo, ¤t_status.files, &files)?; + } else { + restore_worktree_files(&repo, ¤t_status.files, &files)?; + } - status_for_repo(&repo) -} - -#[tauri::command] -pub fn restore_files(path: String, files: Vec, staged: bool) -> Result { - let repo = resolve_repo(&path)?; - validate_files(&files)?; - - if files.is_empty() { - return status_for_repo(&repo); - } - - let current_status = status_for_repo(&repo)?; - if staged { - restore_staged_files(&repo, ¤t_status.files, &files)?; - } else { - restore_worktree_files(&repo, ¤t_status.files, &files)?; - } - - status_for_repo(&repo) + status_for_repo(&repo) + }) + .await + .map_err(|err| format!("Could not restore files: {err}"))? } #[tauri::command] @@ -1055,47 +1067,55 @@ pub fn apply_file_patch( } #[tauri::command] -pub fn commit(path: String, message: String) -> Result { - let repo = resolve_repo(&path)?; - if message.trim().is_empty() { - return Err("Commit message must not be empty.".to_string()); - } +pub async fn commit(path: String, message: String) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + if message.trim().is_empty() { + return Err("Commit message must not be empty.".to_string()); + } - let current_status = status_for_repo(&repo)?; - if has_unresolved_conflicts(¤t_status) { - return Err("Merge conflicts must be resolved before you can commit.".to_string()); - } + let current_status = status_for_repo(&repo)?; + if has_unresolved_conflicts(¤t_status) { + return Err("Merge conflicts must be resolved before you can commit.".to_string()); + } - run_git(&repo, ["commit", "-m", message.as_str()])?; - status_for_repo(&repo) + run_git(&repo, ["commit", "-m", message.as_str()])?; + status_for_repo(&repo) + }) + .await + .map_err(|err| format!("Could not commit: {err}"))? } #[tauri::command] -pub fn amend_commit(path: String, message: Option) -> Result { - let repo = resolve_repo(&path)?; - if verify_commit(&repo, "HEAD").is_err() { - return Err("There is no commit to amend.".to_string()); - } - - let current_status = status_for_repo(&repo)?; - if has_unresolved_conflicts(¤t_status) { - return Err("Merge conflicts must be resolved before you can commit.".to_string()); - } - - let message = message - .map(|message| message.trim().to_string()) - .filter(|message| !message.is_empty()); - - match message { - Some(message) => { - run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?; +pub async fn amend_commit(path: String, message: Option) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + if verify_commit(&repo, "HEAD").is_err() { + return Err("There is no commit to amend.".to_string()); } - None => { - run_git(&repo, ["commit", "--amend", "--no-edit"])?; - } - } - status_for_repo(&repo) + let current_status = status_for_repo(&repo)?; + if has_unresolved_conflicts(¤t_status) { + return Err("Merge conflicts must be resolved before you can commit.".to_string()); + } + + let message = message + .map(|message| message.trim().to_string()) + .filter(|message| !message.is_empty()); + + match message { + Some(message) => { + run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?; + } + None => { + run_git(&repo, ["commit", "--amend", "--no-edit"])?; + } + } + + status_for_repo(&repo) + }) + .await + .map_err(|err| format!("Could not amend commit: {err}"))? } #[tauri::command] @@ -1132,39 +1152,43 @@ pub fn undo_last_commit(path: String) -> Result { } #[tauri::command] -pub fn pull( +pub async fn pull( path: String, username: Option, password: Option, ) -> Result { - let repo = resolve_repo(&path)?; - let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"]; - let output = match (username.as_deref(), password.as_deref()) { - (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { - run_git_authenticated_output(&repo, pull_args, u, p)? + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"]; + let output = match (username.as_deref(), password.as_deref()) { + (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { + run_git_authenticated_output(&repo, pull_args, u, p)? + } + _ => git_command() + .arg("-C") + .arg(&repo) + .args(pull_args) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, + }; + + if output.status.success() { + return status_for_repo(&repo); } - _ => git_command() - .arg("-C") - .arg(&repo) - .args(pull_args) - .output() - .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, - }; - if output.status.success() { - return status_for_repo(&repo); - } + let status = status_for_repo(&repo)?; + if has_unresolved_conflicts(&status) { + return Ok(status); + } - let status = status_for_repo(&repo)?; - if has_unresolved_conflicts(&status) { - return Ok(status); - } - - let details = command_output_details(&output); - if is_auth_error(&details) { - return Err(format!("AUTH_FAILED:{details}")); - } - Err(format!("Git command failed: {details}")) + let details = command_output_details(&output); + if is_auth_error(&details) { + return Err(format!("AUTH_FAILED:{details}")); + } + Err(format!("Git command failed: {details}")) + }) + .await + .map_err(|err| format!("Could not pull: {err}"))? } #[tauri::command] @@ -1203,22 +1227,26 @@ pub async fn fetch( } #[tauri::command] -pub fn push( +pub async fn push( path: String, username: Option, password: Option, ) -> Result { - let repo = resolve_repo(&path)?; - let push_args = push_args_for_repo(&repo)?; - match (username.as_deref(), password.as_deref()) { - (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { - run_git_authenticated(&repo, push_args, u, p)?; + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let push_args = push_args_for_repo(&repo)?; + match (username.as_deref(), password.as_deref()) { + (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { + run_git_authenticated(&repo, push_args, u, p)?; + } + _ => { + run_git(&repo, push_args)?; + } } - _ => { - run_git(&repo, push_args)?; - } - } - status_for_repo(&repo) + status_for_repo(&repo) + }) + .await + .map_err(|err| format!("Could not push: {err}"))? } // ── Credential storage (OS keychain) ──────────────────────────────────────── @@ -1388,61 +1416,69 @@ pub fn cred_delete(key: String) -> Result<(), String> { } #[tauri::command] -pub fn merge_branch(path: String, branch: String) -> Result { - let repo = resolve_repo(&path)?; - let branch = branch.trim(); - if branch.is_empty() { - return Err("Branch name must not be empty.".to_string()); - } +pub async fn merge_branch(path: String, branch: String) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let branch = branch.trim(); + if branch.is_empty() { + return Err("Branch name must not be empty.".to_string()); + } - let output = git_command() - .arg("-C") - .arg(&repo) - .args(["merge", "--no-edit", branch]) - .output() - .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["merge", "--no-edit", branch]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; - if output.status.success() { - return status_for_repo(&repo); - } + if output.status.success() { + return status_for_repo(&repo); + } - // A merge that stops on conflicts leaves unmerged paths in the work tree. - // Surface those through the status so the UI can offer conflict resolution - // instead of treating the conflict as a hard error. - let status = status_for_repo(&repo)?; - if has_unresolved_conflicts(&status) { - return Ok(status); - } + // A merge that stops on conflicts leaves unmerged paths in the work tree. + // Surface those through the status so the UI can offer conflict resolution + // instead of treating the conflict as a hard error. + let status = status_for_repo(&repo)?; + if has_unresolved_conflicts(&status) { + return Ok(status); + } - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let details = if !stderr.trim().is_empty() { - stderr.trim() - } else if !stdout.trim().is_empty() { - stdout.trim() - } else { - "unknown error" - }; + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = if !stderr.trim().is_empty() { + stderr.trim() + } else if !stdout.trim().is_empty() { + stdout.trim() + } else { + "unknown error" + }; - Err(format!("Merge failed: {details}")) + Err(format!("Merge failed: {details}")) + }) + .await + .map_err(|err| format!("Could not merge: {err}"))? } #[tauri::command] -pub fn rebase_branch(path: String, branch: String) -> Result { - let repo = resolve_repo(&path)?; - let branch = branch.trim(); - if branch.is_empty() { - return Err("Branch name must not be empty.".to_string()); - } +pub async fn rebase_branch(path: String, branch: String) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let branch = branch.trim(); + if branch.is_empty() { + return Err("Branch name must not be empty.".to_string()); + } - let output = git_command() - .arg("-C") - .arg(&repo) - .args(["rebase", branch]) - .output() - .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["rebase", branch]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; - rebase_status_or_error(&repo, output, "Rebase failed", true) + rebase_status_or_error(&repo, output, "Rebase failed", true) + }) + .await + .map_err(|err| format!("Could not rebase: {err}"))? } #[tauri::command] @@ -1494,19 +1530,23 @@ fn rebase_status_or_error( } #[tauri::command] -pub fn cherry_pick_commit(path: String, commit: String) -> Result { - let repo = resolve_repo(&path)?; - let commit_hash = verify_commit(&repo, &commit)?; +pub async fn cherry_pick_commit(path: String, commit: String) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let commit_hash = verify_commit(&repo, &commit)?; - let output = git_command() - .arg("-C") - .arg(&repo) - .args(["cherry-pick", commit_hash.as_str()]) - .env("GIT_EDITOR", "true") - .output() - .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["cherry-pick", commit_hash.as_str()]) + .env("GIT_EDITOR", "true") + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; - cherry_pick_status_or_error(&repo, output, "Cherry-pick failed") + cherry_pick_status_or_error(&repo, output, "Cherry-pick failed") + }) + .await + .map_err(|err| format!("Could not cherry-pick: {err}"))? } #[tauri::command] @@ -4841,12 +4881,12 @@ mod tests { })); } - #[test] + #[tokio::test] #[cfg_attr( windows, ignore = "Git for Windows can fail local pull tests with a sh signal pipe error" )] - fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() { + async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() { let repo = init_temp_repo("pull_diverged"); commit_initial_file(&repo.path); let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); @@ -4876,7 +4916,9 @@ mod tests { ], ); - let status = pull(repo.path.to_string_lossy().to_string(), None, None).unwrap(); + let status = pull(repo.path.to_string_lossy().to_string(), None, None) + .await + .unwrap(); assert!(status.clean, "{:?}", status.files); assert!(repo.path.join("remote.txt").exists()); @@ -4925,12 +4967,12 @@ mod tests { ); } - #[test] + #[tokio::test] #[cfg_attr( windows, ignore = "Git for Windows can fail local push tests with a sh signal pipe error" )] - fn push_sets_upstream_for_branch_without_tracking_remote() { + async fn push_sets_upstream_for_branch_without_tracking_remote() { let repo = init_temp_repo("push_sets_upstream_integration"); let remote = init_bare_temp_repo("push_sets_upstream_integration_remote"); commit_initial_file(&repo.path); @@ -4947,7 +4989,9 @@ mod tests { ["remote", "add", "origin", remote.path.to_str().unwrap()], ); - let status = push(repo.path.to_string_lossy().to_string(), None, None).unwrap(); + let status = push(repo.path.to_string_lossy().to_string(), None, None) + .await + .unwrap(); assert_eq!( status.upstream.as_deref(), @@ -5015,8 +5059,8 @@ mod tests { assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); } - #[test] - fn commit_rejects_unresolved_merge_conflicts() { + #[tokio::test] + async fn commit_rejects_unresolved_merge_conflicts() { let repo = init_temp_repo("commit_rejects_conflicts"); fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); run_git_test(&repo.path, ["add", "file.txt"]); @@ -5042,6 +5086,7 @@ mod tests { repo.path.to_string_lossy().to_string(), "should not commit".to_string(), ) + .await .unwrap_err(); assert!(err.contains("Merge conflicts"));