feat(startup): enable startup clone requests from CLI and IPC

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.
This commit is contained in:
Christoph Brandau
2026-08-18 17:41:15 +02:00
parent e4697c74b6
commit 7408371430
7 changed files with 211 additions and 26 deletions
+140 -20
View File
@@ -39,11 +39,68 @@ 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;
@@ -58,27 +115,24 @@ fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path)
}
}
repository.filter(|path| !path.trim().is_empty()).map(|path| {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
cwd.join(path)
}
.to_string_lossy()
.into_owned()
})
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::repository_path_from_args;
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.as_deref(), Some("/home/user/projects/repo"));
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]
@@ -96,7 +150,60 @@ mod startup_repository_tests {
["--repo=projects/repo".to_string()],
Path::new("/home/user"),
);
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
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());
}
}
@@ -105,6 +212,11 @@ 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<StartupCloneRequest> {
state.0.lock().ok()?.take()
}
struct ConsoleLogger;
impl log::Log for ConsoleLogger {
@@ -167,14 +279,20 @@ async fn main() {
return;
}
let startup_repository = repository_path_from_args(
std::env::args().skip(1),
&std::env::current_dir().unwrap_or_default(),
);
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| {
if let Some(path) = repository_path_from_args(args.into_iter().skip(1), Path::new(&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);
}
@@ -195,6 +313,7 @@ async fn main() {
.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());
@@ -318,7 +437,8 @@ async fn main() {
set_telemetry_enabled,
emit_frontend_log,
emit_frontend_span,
take_startup_repository
take_startup_repository,
take_startup_clone
])
.run(tauri::generate_context!())
.expect("error while running tauri application");