#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod badge; mod external_tools; mod git; mod telemetry; use badge::set_sync_badge; use external_tools::{ detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool, }; use git::{ SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository, last_commit_message, list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch, rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit, revert_commit, run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, untrack_paths, update_remote, }; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{Emitter, Manager}; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; struct StartupRepository(Mutex>); #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] struct StartupCloneRequest { remote_url: String, parent_path: String, directory_name: String, } struct StartupClone(Mutex>); fn resolve_startup_path(path: &str, cwd: &Path) -> Option { let path = path.trim(); if path.is_empty() { return None; } let path = PathBuf::from(path); Some(if path.is_absolute() { path } else { cwd.join(path) }) } fn clone_request_from_args( args: impl IntoIterator, cwd: &Path, ) -> Option { let mut args = args.into_iter(); while let Some(arg) = args.next() { let remote_url = if arg == "clone" || arg == "--clone" { args.next() } else { arg.strip_prefix("--clone=").map(ToString::to_string) }; let Some(remote_url) = remote_url.filter(|value| !value.trim().is_empty()) else { continue; }; let target = resolve_startup_path(&args.next()?, cwd)?; let directory_name = target.file_name()?.to_string_lossy().trim().to_string(); let parent_path = target.parent()?.to_string_lossy().into_owned(); if directory_name.is_empty() || parent_path.trim().is_empty() { return None; } return Some(StartupCloneRequest { remote_url, parent_path, directory_name, }); } None } fn repository_path_from_args(args: impl IntoIterator, cwd: &Path) -> Option { let mut args = args.into_iter(); let mut repository = None; while let Some(arg) = args.next() { if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") { return None; } if arg == "--repo" { repository = args.next(); break; } if let Some(path) = arg.strip_prefix("--repo=") { repository = Some(path.to_string()); break; } if !arg.starts_with('-') { repository = Some(arg); break; } } repository .and_then(|path| resolve_startup_path(&path, cwd)) .map(|path| path.to_string_lossy().into_owned()) } #[cfg(test)] mod startup_repository_tests { use super::{clone_request_from_args, repository_path_from_args}; use std::path::Path; #[test] fn accepts_direct_relative_repository_path() { let path = repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user")); assert_eq!( path.map(|value| value.replace('\\', "/")), Some("/home/user/projects/repo".to_string()) ); } #[test] fn accepts_repo_option() { let path = repository_path_from_args( ["--repo".to_string(), "/projects/repo".to_string()], Path::new("/home/user"), ); assert_eq!(path.as_deref(), Some("/projects/repo")); } #[test] fn accepts_repo_equals_option() { let path = repository_path_from_args( ["--repo=projects/repo".to_string()], Path::new("/home/user"), ); assert_eq!( path.map(|value| value.replace('\\', "/")), Some("/home/user/projects/repo".to_string()) ); } #[test] fn accepts_clone_command_with_exact_relative_target() { let request = clone_request_from_args( [ "clone".to_string(), "https://example.com/team/project.git".to_string(), "clones/local-copy".to_string(), ], Path::new("/home/user"), ) .expect("clone request should be parsed"); assert_eq!(request.remote_url, "https://example.com/team/project.git"); assert_eq!(request.directory_name, "local-copy"); assert_eq!(request.parent_path.replace('\\', "/"), "/home/user/clones"); } #[test] fn accepts_clone_option_and_clone_equals_option() { for args in [ vec![ "--clone".to_string(), "git@example.com:team/project.git".to_string(), "/projects/project".to_string(), ], vec![ "--clone=git@example.com:team/project.git".to_string(), "/projects/project".to_string(), ], ] { let request = clone_request_from_args(args, Path::new("/home/user")) .expect("clone option should be parsed"); assert_eq!(request.directory_name, "project"); assert_eq!(request.parent_path.replace('\\', "/"), "/projects"); } } #[test] fn repository_parser_does_not_treat_clone_values_as_repository_paths() { let path = repository_path_from_args( [ "--clone".to_string(), "https://example.com/team/project.git".to_string(), "clones/project".to_string(), ], Path::new("/home/user"), ); assert!(path.is_none()); } } #[tauri::command] fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option { state.0.lock().ok()?.take() } #[tauri::command] fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option { state.0.lock().ok()?.take() } struct ConsoleLogger; impl log::Log for ConsoleLogger { fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { metadata.level() <= log::Level::Info } fn log(&self, record: &log::Record<'_>) { if self.enabled(record.metadata()) { eprintln!( "[{}] [{}] {}", record.level(), record.target(), record.args() ); } } fn flush(&self) {} } static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger; fn init_console_logging() { if log::set_logger(&CONSOLE_LOGGER).is_ok() { log::set_max_level(log::LevelFilter::Info); log::info!(target: "gitty", "Rust console logging initialized"); } } #[tauri::command] fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("splashscreen") { let _ = window.hide(); window .destroy() .map_err(|error| format!("failed to destroy splashscreen: {error}"))?; } if let Some(window) = app.get_webview_window("main") { let _ = window.maximize(); window .show() .map_err(|error| format!("failed to show main window: {error}"))?; let _ = window.set_focus(); } Ok(()) } #[tokio::main] async fn main() { init_console_logging(); telemetry::init(); if let Some(result) = run_sequence_editor_if_requested() { if let Err(error) = result { eprintln!("{error}"); std::process::exit(1); } return; } let startup_args: Vec = std::env::args().skip(1).collect(); let startup_cwd = std::env::current_dir().unwrap_or_default(); let startup_clone = clone_request_from_args(startup_args.clone(), &startup_cwd); let startup_repository = repository_path_from_args(startup_args, &startup_cwd); let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { let args: Vec = args.into_iter().skip(1).collect(); if let Some(request) = clone_request_from_args(args.clone(), Path::new(&cwd)) { if let Ok(mut pending) = app.state::().0.lock() { *pending = Some(request); } let _ = app.emit("open-startup-repository", ()); } else if let Some(path) = repository_path_from_args(args, Path::new(&cwd)) { if let Ok(mut pending) = app.state::().0.lock() { *pending = Some(path); } let _ = app.emit("open-startup-repository", ()); } #[cfg(desktop)] let _ = app .get_webview_window("main") .expect("no main window") .set_focus(); })) .plugin( tauri_plugin_aptabase::Builder::new("A-SH-1344793789") .with_options(tauri_plugin_aptabase::InitOptions { host: Some("https://aptabase.cbsk-tech.de".to_string()), flush_interval: None, }) .build(), ) .manage(StartupRepository(Mutex::new(startup_repository))) .manage(StartupClone(Mutex::new(startup_clone))) .manage(SearchCancellationState::default()) .manage(commit_ai::CommitAiEngine::new()) .plugin(tauri_plugin_dialog::init()); // Linux installs are expected to come from the system package manager (see the // PKGBUILD), which owns updates itself — the self-updater is only wired up for // the platforms whose install method this app ships (NSIS/Windows, .app/macOS). #[cfg(not(target_os = "linux"))] let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder .invoke_handler(tauri::generate_handler![ open_repository, init_repository, clone_repository, open_repo_in_explorer, open_repository_file, detect_external_tools, launch_external_tool, launch_external_diff, launch_external_merge, get_status, git_lfs_status, git_lfs_install, git_lfs_track, git_lfs_untrack, git_lfs_pull, git_lfs_prune, list_branches, list_remotes, add_remote, update_remote, remove_remote, set_branch_upstream, delete_remote_branch, delete_remote_branches, list_stashes, checkout_branch, create_branch, rename_branch, rename_remote_branch, delete_branch, list_worktrees, add_worktree, remove_worktree, move_worktree, lock_worktree, unlock_worktree, prune_worktrees, repair_worktree, list_tags, create_tag, delete_tag, push_tag, cherry_pick_commit, cherry_pick_continue, cherry_pick_abort, stage_files, unstage_files, add_to_gitignore, untrack_paths, stash_push, stash_apply, stash_pop, stash_drop, restore_files, get_file_patch, apply_file_patch, commit, amend_commit, undo_last_commit, last_commit_message, commit_ai_status, commit_ai_load, commit_ai_local_models, commit_ai_generate, commit_ai_review, commit_ai_split, pull, push, fetch, list_commits, get_commit_note, set_commit_note, delete_commit_note, fetch_commit_notes, push_commit_notes, restore_to_commit, restore_file_from_commit, merge_branch, merge_continue, merge_abort, revert_commit, rebase_branch, rebase_continue, rebase_abort, list_interactive_rebase_commits, start_interactive_rebase, list_reflog, restore_reflog_entry, list_repository_files, open_repository_bundle, list_file_history, cancel_file_history, get_file_blame, compare_commits, compare_file_to_head, compare_file_to_parent, diff_file_against_working_tree, search_code_introductions, cancel_code_search, read_conflict, resolve_conflict, resolve_conflict_side, get_remote_url, cred_load, cred_save, cred_delete, set_sync_badge, close_splashscreen, set_telemetry_enabled, emit_frontend_log, emit_frontend_span, take_startup_repository, take_startup_clone ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }