feat(git): convert Git commands to async for better performance

This update modifies several Git command functions to be asynchronous,
improving the responsiveness of the application. By utilizing async
runtime, operations that involve I/O or long-running tasks can now run
without blocking the main thread, enhancing user experience.

- Converted multiple Git command functions to async
- Introduced a helper function to handle async tasks with error management
- Improved overall performance and responsiveness of Git operations
This commit is contained in:
Christoph Brandau
2026-08-11 09:53:34 +02:00
parent 444a7acadd
commit 1fa57eea6f
2 changed files with 494 additions and 315 deletions
+347 -155
View File
@@ -382,13 +382,13 @@ fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(
Ok(())
}
#[tauri::command]
#[tauri::command(async)]
pub fn open_repository(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
status_for_repo(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<GitStatus, String> {
let path = PathBuf::from(path.trim());
if path.as_os_str().is_empty() {
@@ -416,13 +416,13 @@ pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<G
status_for_repo(&path)
}
#[tauri::command]
#[tauri::command(async)]
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
let repo = resolve_repo(&path)?;
open_path_in_file_manager(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
@@ -448,6 +448,56 @@ pub struct RepositoryBundle {
pub files: Vec<GitRepositoryFile>,
}
async fn run_git_task<T, F>(context: &'static str, task: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> Result<T, String> + Send + 'static,
{
tauri::async_runtime::spawn_blocking(task)
.await
.map_err(|error| format!("{context}: {error}"))?
}
fn join_git_worker<T>(name: &str, result: thread::Result<Result<T, String>>) -> Result<T, String> {
result.map_err(|_| format!("The {name} Git worker stopped unexpectedly."))?
}
fn repository_bundle_for_repo(
repo: &Path,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
// Status is needed by the file tree. Once it is available, all remaining
// reads are independent and can run concurrently. Each worker only starts
// read-only Git processes, so this is safe while cutting the former
// branches -> tags -> stashes -> commits -> files waterfall down to the
// duration of its slowest member.
let status = status_for_repo(repo)?;
let (branches, tags, stashes, commits, files) = thread::scope(|scope| -> Result<_, String> {
let branches = scope.spawn(|| branches_for_repo(repo));
let tags = scope.spawn(|| tags_for_repo(repo));
let stashes = scope.spawn(|| stashes_for_repo(repo));
let commits = scope.spawn(|| commits_for_repo(repo, commit_limit));
let files = scope.spawn(|| repository_files_with_status(repo, &status));
Ok((
join_git_worker("branch", branches.join())?,
join_git_worker("tag", tags.join())?,
join_git_worker("stash", stashes.join())?,
join_git_worker("history", commits.join())?,
join_git_worker("file tree", files.join())?,
))
})?;
Ok(RepositoryBundle {
status,
branches,
tags,
stashes,
commits,
files,
})
}
#[tauri::command]
pub async fn clone_repository(
remote_url: String,
@@ -481,40 +531,32 @@ pub async fn open_repository_bundle(
path: String,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<RepositoryBundle, String> {
run_git_task("Could not load repository", move || {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let tags = tags_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
tags,
stashes,
commits,
files,
})
repository_bundle_for_repo(&repo, commit_limit)
})
.await
.map_err(|err| format!("Could not load repository: {err}"))?
}
#[tauri::command]
pub fn get_status(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
status_for_repo(&repo)
pub async fn get_status(path: String) -> Result<GitStatus, String> {
run_git_task("Could not refresh repository status", move || {
let repo = resolve_repo(&path)?;
status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
let repo = resolve_repo(&path)?;
branches_for_repo(&repo)
pub async fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
run_git_task("Could not load branches", move || {
let repo = resolve_repo(&path)?;
branches_for_repo(&repo)
})
.await
}
#[tauri::command]
#[tauri::command(async)]
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}");
let repo = resolve_repo(&path)?;
@@ -535,7 +577,7 @@ pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
.collect())
}
#[tauri::command]
#[tauri::command(async)]
pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, false)?;
@@ -544,7 +586,7 @@ pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemo
list_remotes(path)
}
#[tauri::command]
#[tauri::command(async)]
pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, true)?;
@@ -553,7 +595,7 @@ pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitR
list_remotes(path)
}
#[tauri::command]
#[tauri::command(async)]
pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}");
let result = (|| {
@@ -574,7 +616,7 @@ pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, Strin
result
}
#[tauri::command]
#[tauri::command(async)]
pub fn set_branch_upstream(
path: String,
branch: String,
@@ -607,7 +649,7 @@ pub fn set_branch_upstream(
status_for_repo(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn delete_remote_branch(
path: String,
remote: String,
@@ -634,15 +676,21 @@ pub fn delete_remote_branch(
}
#[tauri::command]
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
let repo = resolve_repo(&path)?;
stashes_for_repo(&repo)
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
run_git_task("Could not load stashes", move || {
let repo = resolve_repo(&path)?;
stashes_for_repo(&repo)
})
.await
}
#[tauri::command]
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
let repo = resolve_repo(&path)?;
tags_for_repo(&repo)
pub async fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
run_git_task("Could not load tags", move || {
let repo = resolve_repo(&path)?;
tags_for_repo(&repo)
})
.await
}
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
@@ -790,39 +838,51 @@ fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
}
#[tauri::command]
pub fn stash_push(
pub async fn stash_push(
path: String,
message: Option<String>,
include_untracked: bool,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let trimmed_message = message.unwrap_or_default().trim().to_string();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
run_git_task("Could not stash changes", move || {
let repo = resolve_repo(&path)?;
let trimmed_message = message.unwrap_or_default().trim().to_string();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
run_git(&repo, args)?;
status_for_repo(&repo)
run_git(&repo, args)?;
status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "apply", selector)
pub async fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
run_git_task("Could not apply stash", move || {
run_stash_update(path, "apply", selector)
})
.await
}
#[tauri::command]
pub fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "pop", selector)
pub async fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
run_git_task("Could not pop stash", move || {
run_stash_update(path, "pop", selector)
})
.await
}
#[tauri::command]
pub fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "drop", selector)
pub async fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
run_git_task("Could not drop stash", move || {
run_stash_update(path, "drop", selector)
})
.await
}
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
@@ -859,7 +919,14 @@ fn validate_stash_selector(selector: &str) -> Result<String, String> {
}
#[tauri::command]
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
pub async fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
run_git_task("Could not check out branch", move || {
checkout_branch_core(path, branch)
})
.await
}
fn checkout_branch_core(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim().to_string();
if branch.is_empty() {
@@ -885,7 +952,18 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
}
#[tauri::command]
pub fn create_branch(
pub async fn create_branch(
path: String,
branch: String,
start_point: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not create branch", move || {
create_branch_core(path, branch, start_point)
})
.await
}
fn create_branch_core(
path: String,
branch: String,
start_point: Option<String>,
@@ -906,7 +984,18 @@ pub fn create_branch(
}
#[tauri::command]
pub fn rename_branch(
pub async fn rename_branch(
path: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
run_git_task("Could not rename branch", move || {
rename_branch_core(path, old_branch, new_branch)
})
.await
}
fn rename_branch_core(
path: String,
old_branch: String,
new_branch: String,
@@ -929,7 +1018,18 @@ pub fn rename_branch(
}
#[tauri::command]
pub fn delete_branch(
pub async fn delete_branch(
path: String,
branch: String,
force: Option<bool>,
) -> Result<GitStatus, String> {
run_git_task("Could not delete branch", move || {
delete_branch_core(path, branch, force)
})
.await
}
fn delete_branch_core(
path: String,
branch: String,
force: Option<bool>,
@@ -1070,13 +1170,43 @@ fn worktrees_for_repo(repo: &Path) -> Result<Vec<GitWorktree>, String> {
}
#[tauri::command]
pub fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
pub async fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not load worktrees", move || {
list_worktrees_core(path)
})
.await
}
fn list_worktrees_core(path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn add_worktree(
pub async fn add_worktree(
path: String,
worktree_path: String,
branch: Option<String>,
new_branch: Option<String>,
start_point: Option<String>,
detached: Option<bool>,
lock: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not add worktree", move || {
add_worktree_core(
path,
worktree_path,
branch,
new_branch,
start_point,
detached,
lock,
)
})
.await
}
fn add_worktree_core(
path: String,
worktree_path: String,
branch: Option<String>,
@@ -1136,7 +1266,18 @@ pub fn add_worktree(
}
#[tauri::command]
pub fn remove_worktree(
pub async fn remove_worktree(
path: String,
worktree_path: String,
force: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not remove worktree", move || {
remove_worktree_core(path, worktree_path, force)
})
.await
}
fn remove_worktree_core(
path: String,
worktree_path: String,
force: Option<bool>,
@@ -1168,7 +1309,18 @@ pub fn remove_worktree(
}
#[tauri::command]
pub fn move_worktree(
pub async fn move_worktree(
path: String,
worktree_path: String,
destination: String,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not move worktree", move || {
move_worktree_core(path, worktree_path, destination)
})
.await
}
fn move_worktree_core(
path: String,
worktree_path: String,
destination: String,
@@ -1191,7 +1343,18 @@ pub fn move_worktree(
}
#[tauri::command]
pub fn lock_worktree(
pub async fn lock_worktree(
path: String,
worktree_path: String,
reason: Option<String>,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not lock worktree", move || {
lock_worktree_core(path, worktree_path, reason)
})
.await
}
fn lock_worktree_core(
path: String,
worktree_path: String,
reason: Option<String>,
@@ -1211,28 +1374,59 @@ pub fn lock_worktree(
}
#[tauri::command]
pub fn unlock_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
pub async fn unlock_worktree(
path: String,
worktree_path: String,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not unlock worktree", move || {
unlock_worktree_core(path, worktree_path)
})
.await
}
fn unlock_worktree_core(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "prune"])?;
worktrees_for_repo(&repo)
pub async fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not prune worktrees", move || {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "prune"])?;
worktrees_for_repo(&repo)
})
.await
}
#[tauri::command]
pub fn repair_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
pub async fn repair_worktree(
path: String,
worktree_path: String,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not repair worktree", move || {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
})
.await
}
#[tauri::command]
pub fn create_tag(
pub async fn create_tag(
path: String,
name: String,
target: Option<String>,
message: Option<String>,
) -> Result<Vec<GitTag>, String> {
run_git_task("Could not create tag", move || {
create_tag_core(path, name, target, message)
})
.await
}
fn create_tag_core(
path: String,
name: String,
target: Option<String>,
@@ -1271,36 +1465,42 @@ pub fn create_tag(
}
#[tauri::command]
pub fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
run_git(&repo, ["tag", "-d", name.as_str()])?;
tags_for_repo(&repo)
pub async fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
run_git_task("Could not delete tag", move || {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
run_git(&repo, ["tag", "-d", name.as_str()])?;
tags_for_repo(&repo)
})
.await
}
#[tauri::command]
pub fn push_tag(
pub async fn push_tag(
path: String,
name: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
let remote = initial_push_remote_name(&repo)?;
let tag_ref = format!("refs/tags/{name}");
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
run_git_task("Could not push tag", move || {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
let remote = initial_push_remote_name(&repo)?;
let tag_ref = format!("refs/tags/{name}");
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
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)?;
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)?;
}
}
Ok(())
Ok(())
})
.await
}
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
@@ -1435,7 +1635,7 @@ pub async fn restore_files(
.map_err(|err| format!("Could not restore files: {err}"))?
}
#[tauri::command]
#[tauri::command(async)]
pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
@@ -1866,7 +2066,7 @@ pub async fn commit_ai_review(
parse_ai_review(&raw)
}
#[tauri::command]
#[tauri::command(async)]
pub fn apply_file_patch(
path: String,
file: String,
@@ -1951,7 +2151,7 @@ pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitSt
.map_err(|err| format!("Could not amend commit: {err}"))?
}
#[tauri::command]
#[tauri::command(async)]
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
@@ -1967,7 +2167,7 @@ pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
})
}
#[tauri::command]
#[tauri::command(async)]
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
@@ -2146,7 +2346,7 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
/// Returns the remote URL used for auth key derivation (upstream remote of the
/// current branch, falling back to `origin`, then the first configured remote).
#[tauri::command]
#[tauri::command(async)]
pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string());
@@ -2301,7 +2501,7 @@ fn push_args_for_repo_to(
])
}
#[tauri::command]
#[tauri::command(async)]
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
let entry = cred_entry(&key)?;
match entry.get_password() {
@@ -2315,7 +2515,7 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
}
}
#[tauri::command]
#[tauri::command(async)]
pub fn cred_save(
key: String,
username: String,
@@ -2336,7 +2536,7 @@ pub fn cred_save(
.map_err(|err| format!("Saving to keychain failed: {err}"))
}
#[tauri::command]
#[tauri::command(async)]
pub fn cred_delete(key: String) -> Result<(), String> {
let entry = cred_entry(&key)?;
match entry.delete_credential() {
@@ -2402,7 +2602,7 @@ pub async fn merge_branch(
.map_err(|err| format!("Could not merge: {err}"))?
}
#[tauri::command]
#[tauri::command(async)]
pub fn merge_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
@@ -2415,7 +2615,7 @@ pub fn merge_continue(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn merge_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
@@ -2425,7 +2625,7 @@ pub fn merge_abort(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn revert_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit = verify_commit(&repo, &commit)?;
@@ -2470,7 +2670,7 @@ pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, St
.map_err(|err| format!("Could not rebase: {err}"))?
}
#[tauri::command]
#[tauri::command(async)]
pub fn list_interactive_rebase_commits(
path: String,
base: String,
@@ -2550,7 +2750,7 @@ pub async fn start_interactive_rebase(
.map_err(|err| format!("Could not run interactive rebase: {err}"))?
}
#[tauri::command]
#[tauri::command(async)]
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
@@ -2580,7 +2780,7 @@ pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
result
}
#[tauri::command]
#[tauri::command(async)]
pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
@@ -2592,7 +2792,7 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>, String> {
let repo = resolve_repo(&path)?;
let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string();
@@ -2610,7 +2810,7 @@ pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>,
parse_reflog(&output)
}
#[tauri::command]
#[tauri::command(async)]
pub fn restore_reflog_entry(
path: String,
commit: String,
@@ -2870,7 +3070,7 @@ pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatu
.map_err(|err| format!("Could not cherry-pick: {err}"))?
}
#[tauri::command]
#[tauri::command(async)]
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !cherry_pick_in_progress(&repo) {
@@ -2888,7 +3088,7 @@ pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
}
#[tauri::command]
#[tauri::command(async)]
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !cherry_pick_in_progress(&repo) {
@@ -2917,13 +3117,16 @@ fn cherry_pick_status_or_error(
}
#[tauri::command]
pub fn list_commits(
pub async fn list_commits(
path: String,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
commit_page_for_repo(&repo, limit, skip)
run_git_task("Could not load commit history", move || {
let repo = resolve_repo(&path)?;
commit_page_for_repo(&repo, limit, skip)
})
.await
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
@@ -2968,9 +3171,12 @@ fn commit_page_for_repo(
}
#[tauri::command]
pub fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
let repo = resolve_repo(&path)?;
repository_files(&repo)
pub async fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
run_git_task("Could not load repository files", move || {
let repo = resolve_repo(&path)?;
repository_files(&repo)
})
.await
}
#[tauri::command]
@@ -3020,7 +3226,7 @@ pub fn cancel_file_history(
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
#[tauri::command]
#[tauri::command(async)]
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
@@ -3318,7 +3524,7 @@ fn search_code_introductions_core(
Ok(hits)
}
#[tauri::command]
#[tauri::command(async)]
pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit_hash = verify_commit(&repo, &commit)?;
@@ -3341,7 +3547,7 @@ pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, Stri
status_for_repo(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn restore_file_from_commit(
path: String,
commit: String,
@@ -3359,7 +3565,7 @@ pub fn restore_file_from_commit(
status_for_repo(&repo)
}
#[tauri::command]
#[tauri::command(async)]
pub fn compare_commits(
path: String,
from: String,
@@ -3417,7 +3623,7 @@ pub fn compare_commits(
})
}
#[tauri::command]
#[tauri::command(async)]
pub fn diff_file_against_working_tree(
path: String,
commit: String,
@@ -3463,7 +3669,7 @@ pub fn diff_file_against_working_tree(
})
}
#[tauri::command]
#[tauri::command(async)]
pub fn compare_file_to_head(
path: String,
commit: String,
@@ -3525,7 +3731,7 @@ pub fn compare_file_to_head(
})
}
#[tauri::command]
#[tauri::command(async)]
pub fn compare_file_to_parent(
path: String,
commit: String,
@@ -3599,7 +3805,7 @@ pub fn compare_file_to_parent(
})
}
#[tauri::command]
#[tauri::command(async)]
pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
@@ -3635,7 +3841,7 @@ pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String>
})
}
#[tauri::command]
#[tauri::command(async)]
pub fn resolve_conflict_side(
path: String,
file: String,
@@ -3677,7 +3883,7 @@ fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<u64> {
}
}
#[tauri::command]
#[tauri::command(async)]
pub fn resolve_conflict(path: String, file: String, content: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
@@ -4073,21 +4279,7 @@ fn clone_repository_core(
run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let tags = tags_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
tags,
stashes,
commits,
files,
})
repository_bundle_for_repo(&repo, commit_limit)
}
fn clone_target_path(
@@ -6747,7 +6939,7 @@ mod tests {
let repo = init_temp_repo("create_branch");
commit_initial_file(&repo.path);
let status = create_branch(
let status = create_branch_core(
repo.path.to_string_lossy().to_string(),
"feature/new-panel".to_string(),
None,
@@ -6760,7 +6952,7 @@ mod tests {
"new branch should exist"
);
let err = create_branch(
let err = create_branch_core(
repo.path.to_string_lossy().to_string(),
"feature/new-panel".to_string(),
None,
@@ -6776,7 +6968,7 @@ mod tests {
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "feature/old-panel"]);
let status = rename_branch(
let status = rename_branch_core(
repo.path.to_string_lossy().to_string(),
"feature/old-panel".to_string(),
"feature/new-panel".to_string(),
@@ -6801,7 +6993,7 @@ mod tests {
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "stale"]);
let status = delete_branch(
let status = delete_branch_core(
repo.path.to_string_lossy().to_string(),
"stale".to_string(),
None,
@@ -6815,7 +7007,7 @@ mod tests {
);
let err =
delete_branch(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
delete_branch_core(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
assert!(err.contains("current branch"));
}
@@ -6831,7 +7023,7 @@ mod tests {
run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]);
run_git_test(&repo.path, ["checkout", "-q", current.as_str()]);
let err = delete_branch(
let err = delete_branch_core(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(false),
@@ -6839,7 +7031,7 @@ mod tests {
.unwrap_err();
assert!(err.contains("not fully merged"));
delete_branch(
delete_branch_core(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(true),
@@ -7216,7 +7408,7 @@ mod tests {
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "feature"]);
let rows = add_worktree(
let rows = add_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("feature".to_string()),
@@ -7234,7 +7426,7 @@ mod tests {
assert!(!linked.is_main);
assert!(linked.clean);
let rows = lock_worktree(
let rows = lock_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("test lock".to_string()),
@@ -7247,13 +7439,13 @@ mod tests {
assert!(linked.locked);
assert_eq!(linked.lock_reason.as_deref(), Some("test lock"));
unlock_worktree(
unlock_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
)
.expect("worktree should unlock");
let rows = remove_worktree(
let rows = remove_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some(false),
+147 -160
View File
@@ -153,6 +153,7 @@
RebaseCommit,
RebasePlanItem,
ReflogEntry,
RepositoryBundle,
StoredCredential,
} from "./lib/types";
@@ -204,6 +205,15 @@
clearIfCurrent: (message: string) => void;
}
interface RepositoryRefreshOptions {
branches?: boolean;
tags?: boolean;
stashes?: boolean;
commits?: boolean;
files?: boolean;
fileHistory?: boolean;
}
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
@@ -372,7 +382,6 @@
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2;
const STARTUP_SPLASH_MIN_VISIBLE_MS = 850;
const STARTUP_FETCH_MAX_WAIT_MS = 20_000;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false;
@@ -482,12 +491,16 @@
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
window.addEventListener("beforeunload", handleAppShutdown);
window.addEventListener("pagehide", handleAppShutdown);
void getCurrentWindow().onCloseRequested(() => handleAppShutdown()).then((unlisten) => {
if (appShuttingDown) unlisten();
else unlistenCloseRequested = unlisten;
}).catch(() => {
// Browser preview has no Tauri window; DOM lifecycle events still cover it.
});
try {
void getCurrentWindow().onCloseRequested(() => handleAppShutdown()).then((unlisten) => {
if (appShuttingDown) unlisten();
else unlistenCloseRequested = unlisten;
}).catch(() => {
// Browser preview has no Tauri window; DOM lifecycle events still cover it.
});
} catch {
// getCurrentWindow itself throws synchronously in a plain browser preview.
}
});
onDestroy(() => {
@@ -568,16 +581,15 @@
try {
await waitForStartupPaint();
await Promise.race([
fetchOpenRepositoriesDuringStartup(),
wait(STARTUP_FETCH_MAX_WAIT_MS),
]);
} finally {
const remainingSplashTime = STARTUP_SPLASH_MIN_VISIBLE_MS - (performance.now() - startupStartedAt);
if (remainingSplashTime > 0) await wait(remainingSplashTime);
await closeStartupSplashscreen();
startBackgroundTimers();
// Remote access can take seconds (offline networks, SSH negotiation,
// credential helpers). It must never hold the startup screen hostage.
void fetchOpenRepositoriesDuringStartup();
void backgroundRepoStatusTick(false);
}
}
@@ -765,23 +777,14 @@
// now-active repo's name and data with this one's.
if (!sameRepoPath(path, activeRepoPath)) return;
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(path, Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1);
if (!sameRepoPath(path, activeRepoPath)) return;
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(path, bundle.branches);
await refreshTags(path, bundle.tags);
await refreshStashes(path, bundle.stashes);
await refreshCommitHistory(path, bundle.commits);
await refreshExplorerFiles(path, bundle.files);
await applyRepositoryBundle(path, bundle);
// File history reflects `git log`, which only changes when HEAD actually moves
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
// working-tree/status change (staging, edits) doesn't keep re-fetching and
// flickering the currently viewed file's history.
if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
await refreshFileHistory(path);
}
} catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false;
}
@@ -1114,10 +1117,7 @@
aiCommitSplitOpen = false;
aiCommitPlan = null;
commitMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length });
});
commitAiSplitting = false;
@@ -1895,15 +1895,21 @@
// ── Refresh helpers ────────────────────────────────────────────────────────
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
branches = prefetched ?? (await listBranches(path));
const nextBranches = prefetched ?? (await listBranches(path));
if (!sameRepoPath(path, activeRepoPath)) return;
branches = nextBranches;
}
async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
tags = prefetched ?? (await listTags(path));
const nextTags = prefetched ?? (await listTags(path));
if (!sameRepoPath(path, activeRepoPath)) return;
tags = nextTags;
}
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
stashes = prefetched ?? (await listStashes(path));
const nextStashes = prefetched ?? (await listStashes(path));
if (!sameRepoPath(path, activeRepoPath)) return;
stashes = nextStashes;
}
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
@@ -1960,7 +1966,9 @@
}
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
repoFiles = prefetched ?? (await listRepositoryFiles(path));
const nextFiles = prefetched ?? (await listRepositoryFiles(path));
if (!sameRepoPath(path, activeRepoPath)) return;
repoFiles = nextFiles;
const folderPaths = allExplorerFolderPaths(repoFiles);
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
@@ -1970,11 +1978,63 @@
}
}
async function refreshRepositoryViews(
path = activeRepoPath,
options: RepositoryRefreshOptions = {},
) {
const tasks: Promise<void>[] = [];
if (options.branches ?? true) tasks.push(refreshBranchList(path));
if (options.tags ?? false) tasks.push(refreshTags(path));
if (options.stashes ?? false) tasks.push(refreshStashes(path));
if (options.commits ?? true) tasks.push(refreshCommitHistory(path));
if (options.files ?? true) tasks.push(refreshExplorerFiles(path));
// These reads do not depend on each other. Starting them together removes
// several IPC/Git-process waterfalls after every user operation.
await Promise.all(tasks);
// Explorer refresh may invalidate the selected path, so file history runs
// after the parallel group rather than racing a disappearing selection.
if (options.fileHistory ?? true) await refreshFileHistory(path);
}
async function applyRepositoryBundle(
path: string,
bundle: RepositoryBundle,
forceFileHistory = false,
) {
const previousHeadHash = lastFileHistoryHeadHash;
applyStatus(bundle.status);
const resolvedPath = activeRepoPath || path;
await Promise.all([
refreshBranchList(resolvedPath, bundle.branches),
refreshTags(resolvedPath, bundle.tags),
refreshStashes(resolvedPath, bundle.stashes),
refreshCommitHistory(resolvedPath, bundle.commits),
refreshExplorerFiles(resolvedPath, bundle.files),
]);
if (forceFileHistory || lastFileHistoryHeadHash !== previousHeadHash) {
await refreshFileHistory(resolvedPath);
}
}
async function refreshRepositorySnapshot(path = activeRepoPath, forceFileHistory = false) {
const bundle = await openRepositoryBundle(
path,
Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1,
);
if (!sameRepoPath(path, activeRepoPath)) return;
await applyRepositoryBundle(path, bundle, forceFileHistory);
}
async function refreshRefsAndCommitGraph(path = activeRepoPath) {
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(path);
await refreshTags(path);
await refreshCommitHistory(path);
await Promise.all([
refreshBranchList(path),
refreshTags(path),
refreshCommitHistory(path),
]);
if (lastFileHistoryHeadHash !== previousHeadHash && fileHistoryDialogOpen) {
await refreshFileHistory(path);
}
@@ -2050,14 +2110,9 @@
const bundle = await openRepositoryBundle(path, COMMIT_HISTORY_PAGE_SIZE + 1);
if (requestId !== repoOpenRequestId) return;
resetRepositoryState(false);
applyStatus(bundle.status);
await applyRepositoryBundle(path, bundle);
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
trackEvent("repository_opened", {
changed_files: bundle.status.files.length,
@@ -2124,14 +2179,9 @@
COMMIT_HISTORY_PAGE_SIZE + 1,
);
resetRepositoryState(false);
applyStatus(bundle.status);
await applyRepositoryBundle(bundle.status.repo_path, bundle);
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
cloneDialogOpen = false;
pendingClone = null;
if (credDialogAction === "clone") {
@@ -2350,12 +2400,7 @@
async function refreshRepo() {
if (!activeRepoPath) { await openRepo(); return; }
await runOperation("Refreshing", async () => {
applyStatus(await getStatus(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshStashes(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositorySnapshot(activeRepoPath, true);
trackEvent("repository_refreshed", {
changed_files: status?.files.length ?? 0,
});
@@ -2366,10 +2411,7 @@
if (!activeRepoPath || branch.current) return;
await runOperation(`Checking out ${branch.name}`, async () => {
applyStatus(await checkoutBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_checked_out", {
remote: branch.remote ? 1 : 0,
});
@@ -2381,10 +2423,7 @@
if (!activeRepoPath || !name) return;
await runOperation(`Creating ${name}`, async () => {
applyStatus(await createBranch(activeRepoPath, name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_created");
});
}
@@ -2403,10 +2442,7 @@
await runOperation(`Renaming ${branch.name}`, async () => {
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
renameBranchTarget = null;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_renamed");
});
}
@@ -2454,10 +2490,7 @@
applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce));
deleteBranchTarget = null;
deleteBranchForce = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_deleted", {
force: forceDelete ? 1 : 0,
});
@@ -2633,10 +2666,7 @@
await runOperation(`Creating ${name}`, async () => {
applyStatus(await createBranch(activeRepoPath, name, target.hash));
newBranchCommit = null;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_created_from_commit");
});
}
@@ -2648,10 +2678,7 @@
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
await runOperation(`Merging ${branch.name}`, async () => {
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_merged", {
remote: branch.remote ? 1 : 0,
});
@@ -2662,10 +2689,7 @@
if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return;
await runOperation(`Rebasing onto ${branch.name}`, async () => {
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_rebased", {
remote: branch.remote ? 1 : 0,
});
@@ -2676,10 +2700,7 @@
if (!activeRepoPath || !rebaseInProgress || hasConflicts) return;
await runOperation("Continuing rebase", async () => {
applyStatus(await rebaseContinue(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("rebase_continued");
});
}
@@ -2695,10 +2716,7 @@
resolveDialogOpen = false;
conflict = null;
conflictTarget = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("rebase_aborted");
});
}
@@ -2742,10 +2760,7 @@
await runOperation("Starting interactive rebase", async () => {
applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan));
interactiveRebaseOpen = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("interactive_rebase_started", { commits: plan.length });
});
if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage;
@@ -2787,10 +2802,7 @@
await runOperation("Restoring reflog entry", async () => {
applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim()));
reflogOpen = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("reflog_recovered");
});
if (reflogOpen && errorMessage) reflogError = errorMessage;
@@ -2843,10 +2855,7 @@
if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
await runOperation(`Cherry-picking ${commit.short_hash}`, async () => {
applyStatus(await cherryPickCommit(activeRepoPath, commit.hash));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("commit_cherry_picked");
});
}
@@ -2855,10 +2864,7 @@
if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return;
await runOperation("Continuing cherry-pick", async () => {
applyStatus(await cherryPickContinue(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("cherry_pick_continued");
});
}
@@ -2874,10 +2880,7 @@
resolveDialogOpen = false;
conflict = null;
conflictTarget = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("cherry_pick_aborted");
});
}
@@ -2953,10 +2956,7 @@
errorMessage = "";
await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("repository_pulled", {
from_stored_credential: fromStore ? 1 : 0,
changed_files: status?.files.length ?? 0,
@@ -2995,9 +2995,7 @@
await runOperation("Pushing", async () => {
applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
remoteActionForceWithLease = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { files: false });
trackEvent("repository_pushed", {
from_stored_credential: fromStore ? 1 : 0,
changed_files: status?.files.length ?? 0,
@@ -3021,10 +3019,7 @@
await runOperation("Pulling before push", async () => {
applyStatus(await pull(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
});
if (errorMessage) {
@@ -3041,9 +3036,7 @@
await runOperation("Pushing after pull", async () => {
applyStatus(await push(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { files: false });
trackEvent("repository_pushed_after_pull", {
from_stored_credential: fromStore ? 1 : 0,
changed_files: status?.files.length ?? 0,
@@ -3137,18 +3130,24 @@
if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return;
await runOperation(`Reverting ${commit.short_hash}`, async () => {
applyStatus(await revertCommit(activeRepoPath, commit.hash));
await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { branches: false });
});
}
async function continueMerge() {
if (!activeRepoPath) return;
await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); });
await runOperation("Continuing merge", async () => {
applyStatus(await mergeContinue(activeRepoPath));
await refreshRepositoryViews(activeRepoPath);
});
}
async function abortMerge() {
if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return;
await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); });
await runOperation("Aborting merge", async () => {
applyStatus(await mergeAbort(activeRepoPath));
await refreshRepositoryViews(activeRepoPath);
});
}
async function fetchPruneRepo() {
@@ -3205,9 +3204,11 @@
const stashedFiles = changedFiles.length;
await runOperation("Stashing changes", async () => {
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, {
branches: false,
stashes: true,
commits: false,
});
trackEvent("stash_saved", {
include_untracked: includeUntracked ? 1 : 0,
changed_files: stashedFiles,
@@ -3219,9 +3220,11 @@
if (!activeRepoPath) return;
await runOperation(`Applying ${stash.selector}`, async () => {
applyStatus(await stashApply(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, {
branches: false,
stashes: true,
commits: false,
});
trackEvent("stash_applied");
});
}
@@ -3230,9 +3233,11 @@
if (!activeRepoPath) return;
await runOperation(`Popping ${stash.selector}`, async () => {
applyStatus(await stashPop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, {
branches: false,
stashes: true,
commits: false,
});
trackEvent("stash_popped");
});
}
@@ -3303,8 +3308,7 @@
const paths = files.map((file) => file.path);
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
applyStatus(await restoreFiles(activeRepoPath, paths, staged));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
trackEvent("file_discarded", {
files: files.length,
staged: staged ? 1 : 0,
@@ -3323,8 +3327,7 @@
if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true);
if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false);
if (nextStatus) applyStatus(nextStatus);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
trackEvent("file_discarded", {
files: files.length,
staged: 2,
@@ -3425,8 +3428,7 @@
try {
applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
if (updatedPatch.trim()) {
@@ -3532,10 +3534,7 @@
amendMode = false;
preAmendDraftMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("commit_created", { amend: 1 });
});
return;
@@ -3546,10 +3545,7 @@
applyStatus(await commit(activeRepoPath, message));
commitMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("commit_created", { amend: 0, staged_files: trackedStagedCount });
});
}
@@ -3591,10 +3587,7 @@
commitMessage = preAmendDraftMessage;
preAmendDraftMessage = "";
}
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("commit_undone");
});
}
@@ -3607,10 +3600,7 @@
if (!confirmed) return;
await runOperation(`Restoring ${target.short_hash}`, async () => {
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath);
trackEvent("commit_restored");
});
}
@@ -3621,8 +3611,7 @@
if (!confirmed) return false;
await runOperation(`Restoring ${file.path}`, async () => {
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
trackEvent("commit_file_restored");
});
return !errorMessage;
@@ -3767,8 +3756,7 @@
if (!confirmed) return;
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
trackEvent("selected_file_restored_from_commit", {
kind,
});
@@ -3960,8 +3948,7 @@
}
preparedResolutions = {};
if (nextStatus) applyStatus(nextStatus);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
(f) => f.staged === "conflicted" || f.unstaged === "conflicted",