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
+4
View File
@@ -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. the Git index without deleting their working-tree contents.
- Gitty can open a repository directly at startup through the `--repo PATH` or - Gitty can open a repository directly at startup through the `--repo PATH` or
`--repo=PATH` command-line argument. `--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 ### Changed
+21
View File
@@ -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=<REMOTE>` 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 ## Git LFS
Gitty bundles the `git-lfs` executable in its desktop installers and checks it Gitty bundles the `git-lfs` executable in its desktop installers and checks it
+140 -20
View File
@@ -39,11 +39,68 @@ use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
struct StartupRepository(Mutex<Option<String>>); 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> { fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
let mut args = args.into_iter(); let mut args = args.into_iter();
let mut repository = None; let mut repository = None;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") {
return None;
}
if arg == "--repo" { if arg == "--repo" {
repository = args.next(); repository = args.next();
break; 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| { repository
let path = PathBuf::from(path); .and_then(|path| resolve_startup_path(&path, cwd))
if path.is_absolute() { .map(|path| path.to_string_lossy().into_owned())
path
} else {
cwd.join(path)
}
.to_string_lossy()
.into_owned()
})
} }
#[cfg(test)] #[cfg(test)]
mod startup_repository_tests { mod startup_repository_tests {
use super::repository_path_from_args; use super::{clone_request_from_args, repository_path_from_args};
use std::path::Path; use std::path::Path;
#[test] #[test]
fn accepts_direct_relative_repository_path() { fn accepts_direct_relative_repository_path() {
let path = repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user")); let path =
assert_eq!(path.as_deref(), Some("/home/user/projects/repo")); 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] #[test]
@@ -96,7 +150,60 @@ mod startup_repository_tests {
["--repo=projects/repo".to_string()], ["--repo=projects/repo".to_string()],
Path::new("/home/user"), 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() state.0.lock().ok()?.take()
} }
#[tauri::command]
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
state.0.lock().ok()?.take()
}
struct ConsoleLogger; struct ConsoleLogger;
impl log::Log for ConsoleLogger { impl log::Log for ConsoleLogger {
@@ -167,14 +279,20 @@ async fn main() {
return; return;
} }
let startup_repository = repository_path_from_args( let startup_args: Vec<String> = std::env::args().skip(1).collect();
std::env::args().skip(1), let startup_cwd = std::env::current_dir().unwrap_or_default();
&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() let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { .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() { if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
*pending = Some(path); *pending = Some(path);
} }
@@ -195,6 +313,7 @@ async fn main() {
.build(), .build(),
) )
.manage(StartupRepository(Mutex::new(startup_repository))) .manage(StartupRepository(Mutex::new(startup_repository)))
.manage(StartupClone(Mutex::new(startup_clone)))
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new()) .manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init()); .plugin(tauri_plugin_dialog::init());
@@ -318,7 +437,8 @@ async fn main() {
set_telemetry_enabled, set_telemetry_enabled,
emit_frontend_log, emit_frontend_log,
emit_frontend_span, emit_frontend_span,
take_startup_repository take_startup_repository,
take_startup_clone
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+18 -3
View File
@@ -288,6 +288,7 @@
let repoPath = ""; let repoPath = "";
let startupReady = false; let startupReady = false;
let pendingStartupRepoPath = ""; let pendingStartupRepoPath = "";
let pendingStartupCloneRequest: CloneRequest | null = null;
let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined; let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined;
let unlistenStartupRepository: (() => void) | undefined; let unlistenStartupRepository: (() => void) | undefined;
let activeRepoPath = ""; let activeRepoPath = "";
@@ -683,13 +684,21 @@
async function receiveStartupRepository() { async function receiveStartupRepository() {
try { try {
const path = await tracedInvoke<string | null>("take_startup_repository"); const [path, cloneRequest] = await Promise.all([
if (path) pendingStartupRepoPath = path; tracedInvoke<string | null>("take_startup_repository"),
tracedInvoke<CloneRequest | null>("take_startup_clone"),
]);
if (cloneRequest) {
pendingStartupCloneRequest = cloneRequest;
pendingStartupRepoPath = "";
} else if (path) {
pendingStartupRepoPath = path;
}
} catch { } catch {
return; return;
} }
if (!startupReady || !pendingStartupRepoPath) return; if (!startupReady || (!pendingStartupCloneRequest && !pendingStartupRepoPath)) return;
if (isBusy) { if (isBusy) {
if (!startupRepositoryRetryTimer) { if (!startupRepositoryRetryTimer) {
startupRepositoryRetryTimer = setTimeout(() => { startupRepositoryRetryTimer = setTimeout(() => {
@@ -699,6 +708,12 @@
} }
return; return;
} }
if (pendingStartupCloneRequest) {
const request = pendingStartupCloneRequest;
pendingStartupCloneRequest = null;
await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName);
return;
}
const path = pendingStartupRepoPath; const path = pendingStartupRepoPath;
pendingStartupRepoPath = ""; pendingStartupRepoPath = "";
await openRepo(path); await openRepo(path);
+25
View File
@@ -4277,6 +4277,31 @@
.tool-surface-choice > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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: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); } .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 { max-width: 170px; min-width: 0; }
.compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+2 -2
View File
@@ -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.", "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.", "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.", "Ü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.", "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.", 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.", "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 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.", "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.", "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.", 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.",
+1 -1
View File
@@ -277,7 +277,7 @@
<span class="eyebrow">{scopeLabel}</span> <span class="eyebrow">{scopeLabel}</span>
<p class="dialog-title" title={displayPath}>{displayPath}</p> <p class="dialog-title" title={displayPath}>{displayPath}</p>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions line-patch-header-actions">
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}> <div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
<span>{isGerman ? "Öffnen mit" : "Open with"}</span> <span>{isGerman ? "Öffnen mit" : "Open with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}> <button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>