From d415cbd3a19e12b4e68c4563604a2088fd84131e Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 2 Jul 2026 20:24:46 +0200 Subject: [PATCH] add upstream --- src-tauri/crates/commit_ai/src/lib.rs | 4 +- src-tauri/src/git.rs | 190 +++++++++++++++++++++----- src-tauri/src/main.rs | 18 +-- 3 files changed, 168 insertions(+), 44 deletions(-) diff --git a/src-tauri/crates/commit_ai/src/lib.rs b/src-tauri/crates/commit_ai/src/lib.rs index 6f4f2e6..20ef80a 100644 --- a/src-tauri/crates/commit_ai/src/lib.rs +++ b/src-tauri/crates/commit_ai/src/lib.rs @@ -110,7 +110,9 @@ impl CommitAiEngine { }; if diff.trim().is_empty() { - return Err("Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_string()); + return Err( + "Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_string(), + ); } let (system, user) = build_messages(diff, notes); diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 39da2c1..b090d1d 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -5,8 +5,8 @@ use std::{ path::{Path, PathBuf}, process::{Command, Output, Stdio}, sync::{ - atomic::{AtomicU64, Ordering}, Arc, Mutex, + atomic::{AtomicU64, Ordering}, }, thread, time::Duration, @@ -606,12 +606,13 @@ pub fn push( 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"], u, p)?; + run_git_authenticated(&repo, push_args, u, p)?; } _ => { - run_git(&repo, ["push"])?; + run_git(&repo, push_args)?; } } status_for_repo(&repo) @@ -670,19 +671,11 @@ fn remote_url_for(repo: &Path, remote: &str) -> Option { return None; } let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if url.is_empty() { - None - } else { - Some(url) - } + if url.is_empty() { None } else { Some(url) } } fn upstream_remote_name(repo: &Path) -> Option { - let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; - let branch = String::from_utf8_lossy(&branch).trim().to_string(); - if branch.is_empty() || branch == "HEAD" { - return None; - } + let branch = current_branch_name(repo).ok()?; let out = git_command() .arg("-C") .arg(repo) @@ -693,11 +686,7 @@ fn upstream_remote_name(repo: &Path) -> Option { return None; } let name = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if name.is_empty() { - None - } else { - Some(name) - } + if name.is_empty() { None } else { Some(name) } } fn first_remote_name(repo: &Path) -> Option { @@ -709,6 +698,50 @@ fn first_remote_name(repo: &Path) -> Option { .map(str::to_string) } +fn current_branch_name(repo: &Path) -> Result { + let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?; + let branch = String::from_utf8_lossy(&branch).trim().to_string(); + if branch.is_empty() || branch == "HEAD" { + return Err("Aktueller Branch konnte nicht ermittelt werden.".to_string()); + } + Ok(branch) +} + +fn branch_has_upstream(repo: &Path) -> bool { + git_command() + .arg("-C") + .arg(repo) + .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn initial_push_remote_name(repo: &Path) -> Result { + if remote_url_for(repo, "origin").is_some() { + return Ok("origin".to_string()); + } + + first_remote_name(repo).ok_or_else(|| { + "Dieser Branch hat keinen Upstream und es ist kein Remote konfiguriert.".to_string() + }) +} + +fn push_args_for_repo(repo: &Path) -> Result, String> { + if branch_has_upstream(repo) { + return Ok(vec![OsString::from("push")]); + } + + let branch = current_branch_name(repo)?; + let remote = initial_push_remote_name(repo)?; + Ok(vec![ + OsString::from("push"), + OsString::from("--set-upstream"), + OsString::from(remote), + OsString::from(branch), + ]) +} + #[tauri::command] pub fn cred_load(key: String) -> Result, String> { let entry = cred_entry(&key)?; @@ -1604,7 +1637,6 @@ fn status_for_repo(repo: &Path) -> Result { let (branch, mut files) = parse_status_output(&output)?; detect_worktree_renames(repo, &mut files); - Ok(GitStatus { repo_path: repo.to_string_lossy().to_string(), current_branch: branch.current_branch, @@ -3170,6 +3202,22 @@ mod tests { } fn init_temp_repo(name: &str) -> TempRepo { + let repo = temp_dir(name); + + run_git_test(&repo.path, ["init", "-q"]); + run_git_test(&repo.path, ["config", "user.email", "test@example.com"]); + run_git_test(&repo.path, ["config", "user.name", "Tester"]); + + repo + } + + fn init_bare_temp_repo(name: &str) -> TempRepo { + let repo = temp_dir(name); + run_git_test(&repo.path, ["init", "--bare", "-q"]); + repo + } + + fn temp_dir(name: &str) -> TempRepo { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after Unix epoch") @@ -3181,9 +3229,6 @@ mod tests { )); fs::create_dir_all(&path).expect("temp repo directory should be created"); - run_git_test(&path, ["init", "-q"]); - run_git_test(&path, ["config", "user.email", "test@example.com"]); - run_git_test(&path, ["config", "user.name", "Tester"]); TempRepo { path } } @@ -3504,14 +3549,18 @@ mod tests { ) .unwrap(); - assert!(comparison - .files - .iter() - .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified)); - assert!(comparison - .files - .iter() - .any(|file| file.path == "added.txt" && file.status == FileStatusKind::Added)); + assert!( + comparison + .files + .iter() + .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified) + ); + assert!( + comparison + .files + .iter() + .any(|file| file.path == "added.txt" && file.status == FileStatusKind::Added) + ); assert!(comparison.patch.contains("second line")); } @@ -3568,10 +3617,12 @@ mod tests { assert_eq!(comparison.to_short, "working tree"); assert!(comparison.to_hash.is_empty()); - assert!(comparison - .files - .iter() - .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified)); + assert!( + comparison + .files + .iter() + .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified) + ); assert!(comparison.patch.contains("working tree change")); } @@ -3705,6 +3756,77 @@ mod tests { assert_eq!(parent_count, 2); } + #[test] + fn push_args_use_set_upstream_for_branch_without_tracking_remote() { + let repo = init_temp_repo("push_sets_upstream"); + let remote = init_bare_temp_repo("push_sets_upstream_remote"); + commit_initial_file(&repo.path); + run_git_test( + &repo.path, + ["checkout", "-q", "-b", "features/ai_commits_local"], + ); + fs::write(repo.path.join("feature.txt"), "feature\n") + .expect("feature file should be written"); + run_git_test(&repo.path, ["add", "feature.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]); + run_git_test( + &repo.path, + ["remote", "add", "origin", remote.path.to_str().unwrap()], + ); + + let args = push_args_for_repo(&repo.path).unwrap(); + let args = args + .iter() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + args, + vec![ + "push", + "--set-upstream", + "origin", + "features/ai_commits_local" + ] + ); + } + + #[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() { + 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); + run_git_test( + &repo.path, + ["checkout", "-q", "-b", "features/ai_commits_local"], + ); + fs::write(repo.path.join("feature.txt"), "feature\n") + .expect("feature file should be written"); + run_git_test(&repo.path, ["add", "feature.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]); + run_git_test( + &repo.path, + ["remote", "add", "origin", remote.path.to_str().unwrap()], + ); + + let status = push(repo.path.to_string_lossy().to_string(), None, None).unwrap(); + + assert_eq!( + status.upstream.as_deref(), + Some("origin/features/ai_commits_local") + ); + let local_head = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + let remote_head = git_output_test( + &remote.path, + ["rev-parse", "refs/heads/features/ai_commits_local"], + ); + assert_eq!(remote_head, local_head); + } + #[test] fn read_and_resolve_conflict_round_trip() { let repo = init_temp_repo("resolve_conflict"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8fa6a28..8fd5f5c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,15 +3,15 @@ mod git; use git::{ - apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit, - commit_ai_generate, commit_ai_status, compare_commits, compare_file_to_head, - compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch, - diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches, - list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer, - open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict, - rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, - restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files, - SearchCancellationState, + SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, + checkout_branch, commit, commit_ai_generate, commit_ai_status, compare_commits, + compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, + delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, + list_branches, list_commits, list_file_history, list_repository_files, merge_branch, + open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, + push, read_conflict, rename_branch, resolve_conflict, resolve_conflict_side, + restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, + stage_files, unstage_files, }; fn main() {