Adds startup clone support with a new clone request type and parsing. It wires a CLI and IPC pathway to forward a clone to a running app. UI and docs were updated to reflect startup clone behavior. - Introduce StartupCloneRequest and argument parsing. - Wire IPC to pass clone requests and clone on startup. - UI updated to queue clone requests and trigger clone.
446 lines
15 KiB
Rust
446 lines
15 KiB
Rust
#![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<Option<String>>);
|
|
|
|
#[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<Option<StartupCloneRequest>>);
|
|
|
|
fn resolve_startup_path(path: &str, cwd: &Path) -> Option<PathBuf> {
|
|
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<Item = String>,
|
|
cwd: &Path,
|
|
) -> Option<StartupCloneRequest> {
|
|
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<Item = String>, cwd: &Path) -> Option<String> {
|
|
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<String> {
|
|
state.0.lock().ok()?.take()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
|
|
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<String> = 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<String> = 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::<StartupClone>().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::<StartupRepository>().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");
|
|
}
|