diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c5145..60b7554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`. the Git index without deleting their working-tree contents. - Gitty can open a repository directly at startup through the `--repo PATH` or `--repo=PATH` command-line argument. +- The executable can clone and immediately open a repository with + `clone REMOTE TARGET`, `--clone REMOTE TARGET`, or `--clone=REMOTE TARGET`. + Relative targets use the caller's working directory, and requests are also + forwarded to an already-running Gitty instance. ### Changed diff --git a/README.md b/README.md index 5d7fa69..c3c8e57 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,27 @@ files. --- +## Command line + +Open an existing repository when Gitty starts: + +```text +gitty.exe --repo "D:\Projects\ExistingRepo" +``` + +Clone a remote into an exact local target folder and open it immediately: + +```text +gitty.exe --clone "https://example.com/team/project.git" "D:\Projects\Project" +gitty clone "git@example.com:team/project.git" "D:\Projects\Project" +``` + +`--clone=` is also accepted. Relative target paths are resolved from +the current working directory. Clone and repository requests are forwarded to +the running window when Gitty is already open. + +--- + ## Git LFS Gitty bundles the `git-lfs` executable in its desktop installers and checks it diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 69d87dd..8c3c827 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -39,11 +39,68 @@ 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; @@ -58,27 +115,24 @@ fn repository_path_from_args(args: impl IntoIterator, 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 { + 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 = 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 = 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); } @@ -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"); diff --git a/src/App.svelte b/src/App.svelte index 4a4c1f6..304825b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -288,6 +288,7 @@ let repoPath = ""; let startupReady = false; let pendingStartupRepoPath = ""; + let pendingStartupCloneRequest: CloneRequest | null = null; let startupRepositoryRetryTimer: ReturnType | undefined; let unlistenStartupRepository: (() => void) | undefined; let activeRepoPath = ""; @@ -683,13 +684,21 @@ async function receiveStartupRepository() { try { - const path = await tracedInvoke("take_startup_repository"); - if (path) pendingStartupRepoPath = path; + const [path, cloneRequest] = await Promise.all([ + tracedInvoke("take_startup_repository"), + tracedInvoke("take_startup_clone"), + ]); + if (cloneRequest) { + pendingStartupCloneRequest = cloneRequest; + pendingStartupRepoPath = ""; + } else if (path) { + pendingStartupRepoPath = path; + } } catch { return; } - if (!startupReady || !pendingStartupRepoPath) return; + if (!startupReady || (!pendingStartupCloneRequest && !pendingStartupRepoPath)) return; if (isBusy) { if (!startupRepositoryRetryTimer) { startupRepositoryRetryTimer = setTimeout(() => { @@ -699,6 +708,12 @@ } return; } + if (pendingStartupCloneRequest) { + const request = pendingStartupCloneRequest; + pendingStartupCloneRequest = null; + await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName); + return; + } const path = pendingStartupRepoPath; pendingStartupRepoPath = ""; await openRepo(path); diff --git a/src/app.css b/src/app.css index 4af95bc..4ef92c9 100644 --- a/src/app.css +++ b/src/app.css @@ -4277,6 +4277,31 @@ .tool-surface-choice > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tool-surface-choice > button:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); } .tool-surface-choice > button.active { border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border)); color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); } + .line-patch-header-actions { --line-patch-header-control-size: var(--ui-control-height); } + .line-patch-header-actions .tool-surface-choice { + height: var(--line-patch-header-control-size); + padding: 0; + overflow: hidden; + border-radius: var(--ui-radius-sm); + } + .line-patch-header-actions .tool-surface-choice > span { + display: inline-flex; + align-items: center; + align-self: stretch; + } + .line-patch-header-actions .tool-surface-choice > button { + height: var(--line-patch-header-control-size); + min-height: var(--line-patch-header-control-size); + border-radius: calc(var(--ui-radius-sm) - 1px); + } + .line-patch-header-actions > .btn-sm { + height: var(--line-patch-header-control-size); + min-height: var(--line-patch-header-control-size); + } + .line-patch-header-actions > .dialog-close { + width: var(--line-patch-header-control-size); + min-width: var(--line-patch-header-control-size); + } .compare-restore { max-width: 170px; min-width: 0; } .compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/src/lib/components/HelpOverlay.svelte b/src/lib/components/HelpOverlay.svelte index 73dc605..dd5fa4a 100644 --- a/src/lib/components/HelpOverlay.svelte +++ b/src/lib/components/HelpOverlay.svelte @@ -1531,7 +1531,7 @@ "Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.", "Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.", "Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Neue und ungetrackte Inhalte können im Changes-Bereich und im File Explorer als exakte Datei, kompletter Ordner oder Dateiendungs-Muster in die .gitignore übernommen werden; die Ordneroption erscheint nur beim Rechtsklick auf einen Ordner. Bereits getrackte Dateien und Ordner lassen sich mit „Stop tracking“ aus dem Git-Index entfernen, bleiben aber auf der Festplatte erhalten. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.", - "Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.", + "Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Mit clone REMOTE ZIEL, --clone REMOTE ZIEL oder --clone=REMOTE ZIEL klont Gitty ein Remote-Repository in den exakt angegebenen lokalen Ordner und öffnet es anschließend. Relative Pfade werden gegen das aktuelle Arbeitsverzeichnis aufgelöst; der Aufruf wird auch an eine bereits laufende Gitty-Instanz weitergegeben.", "Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.", ], note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.", @@ -1647,7 +1647,7 @@ "Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.", "The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.", "The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. In Changes and the File Explorer, new and untracked items can be added to .gitignore as an exact file, a complete folder, or an extension-wide pattern; the folder option only appears for folder selections. Tracked files and folders can be removed from the Git index with Stop tracking while remaining on disk. The selected name, parent path, and affected file count are now clearly separated.", - "Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.", + "Repositories can be opened directly at startup with --repo PATH or --repo=PATH. With clone REMOTE TARGET, --clone REMOTE TARGET, or --clone=REMOTE TARGET, Gitty clones a remote into the exact local folder and opens it afterward. Relative paths are resolved against the current working directory, and requests are forwarded to an already-running Gitty instance.", "Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.", ], note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.", diff --git a/src/lib/components/LinePatchDialog.svelte b/src/lib/components/LinePatchDialog.svelte index d6633b7..3a093da 100644 --- a/src/lib/components/LinePatchDialog.svelte +++ b/src/lib/components/LinePatchDialog.svelte @@ -277,7 +277,7 @@ {scopeLabel}

{displayPath}

-
+
{isGerman ? "Öffnen mit" : "Open with"}