feat(git): refactor Git commands to use async/await pattern

This update modifies several Git command functions to utilize the async/await
pattern, enhancing performance and responsiveness during operations. The changes
allow for non-blocking execution of commands, improving the overall user experience.

- Converted multiple Git command functions to async
- Added error handling for async operations
- Updated related tests to support async execution
This commit is contained in:
Christoph Brandau
2026-07-09 19:17:02 +02:00
parent 0877d5a568
commit 710c6d4a53
2 changed files with 229 additions and 181 deletions
+4 -1
View File
@@ -96,7 +96,10 @@
"Bash(identify src-tauri/icons/GitCat.ico)", "Bash(identify src-tauri/icons/GitCat.ico)",
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")", "Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
"Bash(python3 *)", "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)"
] ]
} }
} }
+225 -180
View File
@@ -789,63 +789,75 @@ fn validate_tag_ref_name(name: &str) -> Result<String, String> {
} }
#[tauri::command] #[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
validate_files(&files)?; let repo = resolve_repo(&path)?;
validate_files(&files)?;
if !files.is_empty() { if !files.is_empty() {
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has // 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 // 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. // instead of leaving the old path's deletion unstaged.
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
let mut add_paths: Vec<String> = Vec::new(); let mut add_paths: Vec<String> = Vec::new();
for file in &files { for file in &files {
match find_status(&current_status.files, file) { match find_status(&current_status.files, file) {
Some(entry) => { Some(entry) => {
if let Some(old_path) = &entry.old_path { if let Some(old_path) = &entry.old_path {
add_paths.push(old_path.clone()); 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] #[tauri::command]
pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
validate_files(&files)?; let repo = resolve_repo(&path)?;
validate_files(&files)?;
if !files.is_empty() {
let current_status = status_for_repo(&repo)?;
unstage_selected_files(&repo, &current_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<String>, staged: bool) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
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)?; let current_status = status_for_repo(&repo)?;
unstage_selected_files(&repo, &current_status.files, &files)?; if staged {
} restore_staged_files(&repo, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo) status_for_repo(&repo)
} })
.await
#[tauri::command] .map_err(|err| format!("Could not restore files: {err}"))?
pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
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, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo)
} }
#[tauri::command] #[tauri::command]
@@ -1055,47 +1067,55 @@ pub fn apply_file_patch(
} }
#[tauri::command] #[tauri::command]
pub fn commit(path: String, message: String) -> Result<GitStatus, String> { pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
if message.trim().is_empty() { let repo = resolve_repo(&path)?;
return Err("Commit message must not be empty.".to_string()); if message.trim().is_empty() {
} return Err("Commit message must not be empty.".to_string());
}
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) { if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string()); return Err("Merge conflicts must be resolved before you can commit.".to_string());
} }
run_git(&repo, ["commit", "-m", message.as_str()])?; run_git(&repo, ["commit", "-m", message.as_str()])?;
status_for_repo(&repo) status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not commit: {err}"))?
} }
#[tauri::command] #[tauri::command]
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> { pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
if verify_commit(&repo, "HEAD").is_err() { let repo = resolve_repo(&path)?;
return Err("There is no commit to amend.".to_string()); 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(&current_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) let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_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] #[tauri::command]
@@ -1132,39 +1152,43 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
} }
#[tauri::command] #[tauri::command]
pub fn pull( pub async fn pull(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"]; let repo = resolve_repo(&path)?;
let output = match (username.as_deref(), password.as_deref()) { let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { let output = match (username.as_deref(), password.as_deref()) {
run_git_authenticated_output(&repo, pull_args, u, p)? (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() { let status = status_for_repo(&repo)?;
return status_for_repo(&repo); if has_unresolved_conflicts(&status) {
} return Ok(status);
}
let status = status_for_repo(&repo)?; let details = command_output_details(&output);
if has_unresolved_conflicts(&status) { if is_auth_error(&details) {
return Ok(status); return Err(format!("AUTH_FAILED:{details}"));
} }
Err(format!("Git command failed: {details}"))
let details = command_output_details(&output); })
if is_auth_error(&details) { .await
return Err(format!("AUTH_FAILED:{details}")); .map_err(|err| format!("Could not pull: {err}"))?
}
Err(format!("Git command failed: {details}"))
} }
#[tauri::command] #[tauri::command]
@@ -1203,22 +1227,26 @@ pub async fn fetch(
} }
#[tauri::command] #[tauri::command]
pub fn push( pub async fn push(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let push_args = push_args_for_repo(&repo)?; let repo = resolve_repo(&path)?;
match (username.as_deref(), password.as_deref()) { let push_args = push_args_for_repo(&repo)?;
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { match (username.as_deref(), password.as_deref()) {
run_git_authenticated(&repo, push_args, u, p)?; (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?;
}
_ => {
run_git(&repo, push_args)?;
}
} }
_ => { status_for_repo(&repo)
run_git(&repo, push_args)?; })
} .await
} .map_err(|err| format!("Could not push: {err}"))?
status_for_repo(&repo)
} }
// ── Credential storage (OS keychain) ──────────────────────────────────────── // ── Credential storage (OS keychain) ────────────────────────────────────────
@@ -1388,61 +1416,69 @@ pub fn cred_delete(key: String) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let branch = branch.trim(); let repo = resolve_repo(&path)?;
if branch.is_empty() { let branch = branch.trim();
return Err("Branch name must not be empty.".to_string()); if branch.is_empty() {
} return Err("Branch name must not be empty.".to_string());
}
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["merge", "--no-edit", branch]) .args(["merge", "--no-edit", branch])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() { if output.status.success() {
return status_for_repo(&repo); return status_for_repo(&repo);
} }
// A merge that stops on conflicts leaves unmerged paths in the work tree. // 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 // Surface those through the status so the UI can offer conflict resolution
// instead of treating the conflict as a hard error. // instead of treating the conflict as a hard error.
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) { if has_unresolved_conflicts(&status) {
return Ok(status); return Ok(status);
} }
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() { let details = if !stderr.trim().is_empty() {
stderr.trim() stderr.trim()
} else if !stdout.trim().is_empty() { } else if !stdout.trim().is_empty() {
stdout.trim() stdout.trim()
} else { } else {
"unknown error" "unknown error"
}; };
Err(format!("Merge failed: {details}")) Err(format!("Merge failed: {details}"))
})
.await
.map_err(|err| format!("Could not merge: {err}"))?
} }
#[tauri::command] #[tauri::command]
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let branch = branch.trim(); let repo = resolve_repo(&path)?;
if branch.is_empty() { let branch = branch.trim();
return Err("Branch name must not be empty.".to_string()); if branch.is_empty() {
} return Err("Branch name must not be empty.".to_string());
}
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["rebase", branch]) .args(["rebase", branch])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .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] #[tauri::command]
@@ -1494,19 +1530,23 @@ fn rebase_status_or_error(
} }
#[tauri::command] #[tauri::command]
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> { pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let commit_hash = verify_commit(&repo, &commit)?; let repo = resolve_repo(&path)?;
let commit_hash = verify_commit(&repo, &commit)?;
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["cherry-pick", commit_hash.as_str()]) .args(["cherry-pick", commit_hash.as_str()])
.env("GIT_EDITOR", "true") .env("GIT_EDITOR", "true")
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .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] #[tauri::command]
@@ -4841,12 +4881,12 @@ mod tests {
})); }));
} }
#[test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error" 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"); let repo = init_temp_repo("pull_diverged");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); 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!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists()); assert!(repo.path.join("remote.txt").exists());
@@ -4925,12 +4967,12 @@ mod tests {
); );
} }
#[test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error" 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 repo = init_temp_repo("push_sets_upstream_integration");
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote"); let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
@@ -4947,7 +4989,9 @@ mod tests {
["remote", "add", "origin", remote.path.to_str().unwrap()], ["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!( assert_eq!(
status.upstream.as_deref(), status.upstream.as_deref(),
@@ -5015,8 +5059,8 @@ mod tests {
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
} }
#[test] #[tokio::test]
fn commit_rejects_unresolved_merge_conflicts() { async fn commit_rejects_unresolved_merge_conflicts() {
let repo = init_temp_repo("commit_rejects_conflicts"); let repo = init_temp_repo("commit_rejects_conflicts");
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
run_git_test(&repo.path, ["add", "file.txt"]); run_git_test(&repo.path, ["add", "file.txt"]);
@@ -5042,6 +5086,7 @@ mod tests {
repo.path.to_string_lossy().to_string(), repo.path.to_string_lossy().to_string(),
"should not commit".to_string(), "should not commit".to_string(),
) )
.await
.unwrap_err(); .unwrap_err();
assert!(err.contains("Merge conflicts")); assert!(err.contains("Merge conflicts"));