From 90df347e4ac80b579c47331ddb82706dbbcec006 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 31 Aug 2026 19:43:30 +0200 Subject: [PATCH] feat(git): add advanced clone options (shallow, sparse, flags) Add advanced clone options support across the frontend and backend. Users can specify a branch, shallow limits, blobless mode, sparse paths, and custom clone flags when initiating a clone operation. The backend validates inputs, parses custom flags without invoking a shell, and configures sparse checkouts after a successful clone. A small parsing dependency was added and tracked-file parsing was improved to better handle git ls-files output. - Introduce CloneRunOptions and validation helpers for clone inputs - Parse custom flags with a shell-like tokenizer and block managed flags - Support sparse checkout configuration and shallow clone constraints --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/git.rs | 329 +++++++++++++++++- src/App.svelte | 15 +- .../components/CloneRepositoryDialog.svelte | 105 +++++- src/lib/git.ts | 9 + src/lib/types.ts | 10 + 7 files changed, 455 insertions(+), 15 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2eb8746..a809c04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1478,6 +1478,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "shlex", "sysinfo", "tauri", "tauri-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3949671..2de9793 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } log = "0.4" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] } +shlex = "2" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 999912f..d90a967 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -632,8 +632,24 @@ pub async fn clone_repository( username: Option, password: Option, commit_limit: Option, + branch: Option, + blobless: Option, + custom_flags: Option, + shallow_depth: Option, + shallow_since: Option, + sparse: Option, + sparse_paths: Option>, ) -> Result { tauri::async_runtime::spawn_blocking(move || { + let clone_options = CloneRunOptions { + branch, + blobless: blobless.unwrap_or(false), + custom_flags: custom_flags.unwrap_or_default(), + shallow_depth, + shallow_since, + sparse: sparse.unwrap_or(false), + sparse_paths: sparse_paths.unwrap_or_default(), + }; clone_repository_core( &remote_url, &parent_path, @@ -641,6 +657,7 @@ pub async fn clone_repository( username.as_deref(), password.as_deref(), commit_limit, + &clone_options, ) }) .await @@ -5405,8 +5422,20 @@ fn repository_files_with_status( ) -> Result, String> { let mut files = BTreeMap::::new(); - let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?; - for path in parse_nul_paths(&tracked_output) { + let tracked_output = run_git(repo, ["ls-files", "-z", "-t", "--cached", "--deleted"])?; + for entry in tracked_output + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + { + let (tag, path_bytes) = if entry.len() >= 2 && entry[1] == b' ' { + (entry[0], &entry[2..]) + } else { + (b'H', entry) + }; + if tag == b'S' { + continue; + } + let path = String::from_utf8_lossy(path_bytes).into_owned(); let status = status_for_file(&status.files, &path); files.insert( path.clone(), @@ -5434,6 +5463,17 @@ fn repository_files_with_status( Ok(files.into_values().collect()) } +#[derive(Debug, Default)] +struct CloneRunOptions { + branch: Option, + blobless: bool, + custom_flags: String, + shallow_depth: Option, + shallow_since: Option, + sparse: bool, + sparse_paths: Vec, +} + fn clone_repository_core( remote_url: &str, parent_path: &str, @@ -5441,9 +5481,16 @@ fn clone_repository_core( username: Option<&str>, password: Option<&str>, commit_limit: Option, + clone_options: &CloneRunOptions, ) -> Result { let target = clone_target_path(remote_url, parent_path, directory_name)?; - run_git_clone(remote_url.trim(), &target, username, password)?; + run_git_clone( + remote_url.trim(), + &target, + username, + password, + clone_options, + )?; let repo = resolve_repo(&target.to_string_lossy())?; let lfs_warning = if verify_commit(&repo, "HEAD").is_ok() { @@ -5564,7 +5611,21 @@ fn run_git_clone( target: &Path, username: Option<&str>, password: Option<&str>, + clone_options: &CloneRunOptions, ) -> Result<(), String> { + if matches!(clone_options.shallow_depth, Some(0)) { + return Err("Shallow clone depth must be at least 1.".to_string()); + } + if clone_options.shallow_depth.is_some() && clone_options.shallow_since.is_some() { + return Err( + "Choose either shallow clone depth or shallow clone date, not both.".to_string(), + ); + } + let branch = validate_clone_branch(clone_options.branch.as_deref())?; + let shallow_since = validate_shallow_since(clone_options.shallow_since.as_deref())?; + let custom_flags = parse_custom_clone_flags(&clone_options.custom_flags)?; + let sparse_paths = validate_sparse_checkout_paths(&clone_options.sparse_paths)?; + let sparse_enabled = clone_options.sparse || !sparse_paths.is_empty(); let mut command = git_command(); let has_explicit_credentials = matches!( (username, password), @@ -5573,8 +5634,24 @@ fn run_git_clone( if has_explicit_credentials { command.arg("-c").arg("credential.helper="); } + command.arg("clone"); + if let Some(branch) = branch { + command.arg("--branch").arg(branch); + } + if let Some(depth) = clone_options.shallow_depth { + command.arg("--depth").arg(depth.to_string()); + } + if let Some(since) = shallow_since { + command.arg("--shallow-since").arg(since); + } + if clone_options.blobless { + command.arg("--filter=blob:none"); + } + if sparse_enabled { + command.arg("--sparse"); + } + command.args(custom_flags); command - .arg("clone") .arg("--") .arg(remote_url) .arg(target) @@ -5601,6 +5678,9 @@ fn run_git_clone( let output = output?; if output.status.success() { + if !sparse_paths.is_empty() { + configure_sparse_checkout(target, &sparse_paths)?; + } return Ok(()); } @@ -5612,6 +5692,104 @@ fn run_git_clone( Err(format!("Git clone failed: {}", details)) } +fn validate_clone_branch(branch: Option<&str>) -> Result, String> { + let Some(branch) = branch.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if branch.starts_with('-') || branch.chars().any(|character| character.is_control()) { + return Err("Branch to clone contains invalid characters.".to_string()); + } + Ok(Some(branch.to_string())) +} + +fn validate_shallow_since(since: Option<&str>) -> Result, String> { + let Some(since) = since.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if since.starts_with('-') || since.chars().any(|character| character.is_control()) { + return Err("Shallow clone date contains invalid characters.".to_string()); + } + Ok(Some(since.to_string())) +} + +fn parse_custom_clone_flags(input: &str) -> Result, String> { + let input = input.trim(); + if input.is_empty() { + return Ok(Vec::new()); + } + let flags = shlex::split(input).ok_or_else(|| { + "Custom clone flags contain an unclosed quote or invalid escape.".to_string() + })?; + const BLOCKED_LONG_FLAGS: &[&str] = &[ + "--bare", + "--branch", + "--config", + "--depth", + "--filter", + "--mirror", + "--no-checkout", + "--reference", + "--reference-if-able", + "--separate-git-dir", + "--shallow-exclude", + "--shallow-since", + "--sparse", + "--template", + "--upload-pack", + ]; + for flag in &flags { + let name = flag.split_once('=').map_or(flag.as_str(), |(name, _)| name); + if flag == "--" + || BLOCKED_LONG_FLAGS.contains(&name) + || matches!(name, "-b" | "-c" | "-n" | "-u") + { + return Err(format!( + "Custom clone flag '{name}' is managed by Gitty or is not allowed." + )); + } + } + Ok(flags) +} + +fn validate_sparse_checkout_paths(paths: &[String]) -> Result, String> { + let mut unique = BTreeSet::new(); + for raw_path in paths { + let path = raw_path.trim(); + if path.is_empty() { + continue; + } + if path.chars().any(|character| character.is_control()) { + return Err("Sparse checkout paths contain invalid control characters.".to_string()); + } + if Path::new(path).is_absolute() + || path == ".." + || path.starts_with("../") + || path.starts_with("..\\") + { + return Err("Sparse checkout paths must be relative to the repository.".to_string()); + } + unique.insert(path.to_string()); + } + if paths.len() > 0 && unique.is_empty() { + return Err("Enter at least one sparse checkout path.".to_string()); + } + Ok(unique.into_iter().collect()) +} + +fn configure_sparse_checkout(target: &Path, paths: &[String]) -> Result<(), String> { + let mut stdin = paths.join("\n"); + stdin.push('\n'); + run_git_with_stdin( + target, + ["sparse-checkout", "set", "--no-cone", "--stdin"], + stdin.as_bytes(), + ) + .map(|_| ()) + .map_err(|error| { + format!("Repository cloned, but sparse checkout could not be configured: {error}") + }) +} + fn is_repository_folder_path(repo: &Path, path: &str) -> Result { let normalized = normalize_git_path(path); if repo.join(path).is_dir() { @@ -7795,6 +7973,7 @@ mod tests { None, None, Some(100), + &CloneRunOptions::default(), ) .expect("repository should clone"); @@ -7812,6 +7991,146 @@ mod tests { assert!(bundle.warning.is_none()); } + #[test] + #[cfg_attr(windows, ignore = "file:// clone URL differs on Windows")] + fn clone_repository_core_supports_shallow_and_sparse_options() { + let source = init_temp_repo("clone_options_source"); + commit_initial_file(&source.path); + fs::create_dir_all(source.path.join("src")).expect("src directory should be created"); + fs::create_dir_all(source.path.join("docs")).expect("docs directory should be created"); + fs::write(source.path.join("src/included.txt"), b"included\n") + .expect("included fixture should be written"); + fs::write(source.path.join("docs/excluded.txt"), b"excluded\n") + .expect("excluded fixture should be written"); + run_git_test( + &source.path, + ["add", "src/included.txt", "docs/excluded.txt"], + ); + run_git_test(&source.path, ["commit", "-q", "-m", "add sparse fixtures"]); + fs::write(source.path.join("src/included.txt"), b"latest\n") + .expect("latest fixture should be written"); + run_git_test(&source.path, ["add", "src/included.txt"]); + run_git_test( + &source.path, + ["commit", "-q", "-m", "update included fixture"], + ); + + let parent = temp_dir("clone_options_parent"); + let remote_url = format!("file://{}", source.path.display()); + let branch = git_output_test(&source.path, ["branch", "--show-current"]); + let clone_options = CloneRunOptions { + branch: Some(branch), + blobless: true, + custom_flags: "--single-branch".to_string(), + shallow_depth: Some(1), + shallow_since: None, + sparse: true, + sparse_paths: vec!["src/".to_string()], + }; + let bundle = clone_repository_core( + &remote_url, + parent.path.to_str().expect("parent path should be UTF-8"), + Some("local-copy"), + None, + None, + Some(100), + &clone_options, + ) + .expect("repository should clone shallow and sparse"); + + let cloned_repo = parent.path.join("local-copy"); + assert!(cloned_repo.join(".git/shallow").exists()); + assert!(cloned_repo.join("src/included.txt").exists()); + assert!(!cloned_repo.join("docs/excluded.txt").exists()); + assert_eq!(bundle.commits.len(), 1); + assert!( + bundle + .files + .iter() + .any(|file| file.path == "src/included.txt") + ); + assert!( + !bundle + .files + .iter() + .any(|file| file.path == "docs/excluded.txt") + ); + + let root_only_parent = temp_dir("clone_sparse_root_parent"); + let root_only_bundle = clone_repository_core( + &remote_url, + root_only_parent + .path + .to_str() + .expect("root-only parent path should be UTF-8"), + Some("local-copy"), + None, + None, + Some(100), + &CloneRunOptions { + sparse: true, + ..CloneRunOptions::default() + }, + ) + .expect("repository should clone sparse with root files only"); + let root_only_repo = root_only_parent.path.join("local-copy"); + assert!(root_only_repo.join("old.txt").exists()); + assert!(!root_only_repo.join("src/included.txt").exists()); + assert!(!root_only_repo.join("docs/excluded.txt").exists()); + assert!( + root_only_bundle + .files + .iter() + .any(|file| file.path == "old.txt") + ); + assert!( + !root_only_bundle + .files + .iter() + .any(|file| file.path == "src/included.txt") + ); + + let since_parent = temp_dir("clone_since_parent"); + let since_options = CloneRunOptions { + branch: clone_options.branch.clone(), + shallow_since: Some("2000-01-01".to_string()), + ..CloneRunOptions::default() + }; + let since_bundle = clone_repository_core( + &remote_url, + since_parent + .path + .to_str() + .expect("since parent path should be UTF-8"), + Some("local-copy"), + None, + None, + Some(100), + &since_options, + ) + .expect("repository should clone with a shallow-since date"); + assert_eq!(since_bundle.status.current_branch, clone_options.branch); + assert!( + since_parent + .path + .join("local-copy/src/included.txt") + .exists() + ); + } + + #[test] + fn custom_clone_flags_are_parsed_without_a_shell_and_managed_flags_are_rejected() { + assert_eq!( + parse_custom_clone_flags("--recurse-submodules --origin 'team remote'") + .expect("custom flags should parse"), + vec!["--recurse-submodules", "--origin", "team remote"] + ); + assert!(parse_custom_clone_flags("--depth 5").is_err()); + assert!(parse_custom_clone_flags("--upload-pack=/tmp/helper").is_err()); + assert!(parse_custom_clone_flags("--config core.hooksPath=/tmp/hooks").is_err()); + assert!(parse_custom_clone_flags("--recurse-submodules '").is_err()); + } + #[test] fn clone_repository_core_supports_empty_repository() { let source = init_bare_temp_repo("empty_clone_source"); @@ -7824,6 +8143,7 @@ mod tests { None, None, Some(100), + &CloneRunOptions::default(), ) .expect("empty repository should clone"); @@ -7882,6 +8202,7 @@ mod tests { None, None, Some(100), + &CloneRunOptions::default(), ) .expect("LFS repository should clone"); let cloned_repo = parent.path.join("local-copy"); diff --git a/src/App.svelte b/src/App.svelte index 5ce729c..d5f5a68 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -148,6 +148,7 @@ AppLanguage, AppTheme, AnalyticsSettings, + CloneOptions, CustomThemeColors, ConflictFile, DetectedExternalTool, @@ -233,6 +234,7 @@ remoteUrl: string; parentPath: string; directoryName: string; + cloneOptions?: CloneOptions; } interface ErrorAutoHideState { @@ -719,7 +721,7 @@ if (pendingStartupCloneRequest) { const request = pendingStartupCloneRequest; pendingStartupCloneRequest = null; - await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName); + await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName, undefined, undefined, undefined, false, "credentials", request.cloneOptions); return; } const path = pendingStartupRepoPath; @@ -2462,12 +2464,13 @@ key?: string | null, fromStore = false, credentialMode: CredentialMode = "credentials", + cloneOptions: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] }, ) { if (isBusy) return; if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; } if (!parentPath) { errorMessage = "Select a destination folder."; return; } - const request: CloneRequest = { remoteUrl, parentPath, directoryName }; + const request: CloneRequest = { remoteUrl, parentPath, directoryName, cloneOptions }; pendingClone = request; const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key; @@ -2483,7 +2486,7 @@ credDialogOpen = true; return; } - await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode); + await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode, cloneOptions); return; } } @@ -2499,6 +2502,7 @@ username, password, COMMIT_HISTORY_PAGE_SIZE + 1, + cloneOptions, ); resetRepositoryState(false); await applyRepositoryBundle(bundle.status.repo_path, bundle); @@ -2555,9 +2559,9 @@ trackEvent("clone_dialog_opened"); } - function cloneFromDialog(remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) { + function cloneFromDialog(remoteUrl: string, parentPath: string, directoryName: string, cloneOptions: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) { const credentialKey = provider ? integrationCredentialKey(provider, accountId) : undefined; - void cloneRepo(remoteUrl, parentPath, directoryName, undefined, undefined, credentialKey, false, provider ? "token" : "credentials"); + void cloneRepo(remoteUrl, parentPath, directoryName, undefined, undefined, credentialKey, false, provider ? "token" : "credentials", cloneOptions); } function openRepoManagement() { @@ -3877,6 +3881,7 @@ key, false, mode, + pendingClone.cloneOptions, ); } } diff --git a/src/lib/components/CloneRepositoryDialog.svelte b/src/lib/components/CloneRepositoryDialog.svelte index 6051ca6..2473ff6 100644 --- a/src/lib/components/CloneRepositoryDialog.svelte +++ b/src/lib/components/CloneRepositoryDialog.svelte @@ -4,7 +4,7 @@ import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte"; import { listIntegrationRepositories } from "../git"; import { configuredIntegrationSources } from "../integrations"; - import type { AppLanguage, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types"; + import type { AppLanguage, CloneOptions, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types"; type CloneSource = "url" | "integrations"; @@ -13,7 +13,7 @@ error: string; language: AppLanguage; integrations: GitIntegrationSettings; - onClone: (remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) => void; + onClone: (remoteUrl: string, parentPath: string, directoryName: string, options: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) => void; onClose: () => void; } @@ -45,6 +45,15 @@ let repositoryScrollbarDragScrollTop = 0; let repositoryScrollbarFrame: number | undefined; let repositoryRequestId = 0; + let shallowClone = $state(false); + let branchToClone = $state(""); + let shallowLimitMode = $state<"depth" | "since">("depth"); + let shallowDepth = $state(1); + let shallowSince = $state(""); + let customFlags = $state(""); + let sparseCheckout = $state(false); + let bloblessClone = $state(false); + let sparsePathInput = $state(""); const isGerman = $derived(language === "de"); const configuredSources = $derived(configuredIntegrationSources(integrations)); @@ -69,7 +78,10 @@ }); const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId)); const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl)); - const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0); + const sparsePaths = $derived.by(() => [...new Set(sparsePathInput.split(/\r?\n/).map((path) => path.trim()).filter(Boolean))]); + const shallowLimitValid = $derived(!shallowClone || (shallowLimitMode === "depth" ? Number.isInteger(shallowDepth) && shallowDepth > 0 : shallowSince.trim().length > 0)); + const cloneOptionsValid = $derived(shallowLimitValid); + const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0 && cloneOptionsValid); $effect(() => { const nextError = error || browseError; @@ -260,7 +272,22 @@ function submit(event: SubmitEvent) { event.preventDefault(); - if (canSubmit) onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim(), source === "integrations" ? activeSource?.provider : undefined, source === "integrations" ? activeSource?.accountId : undefined); + if (canSubmit) onClone( + remoteUrl.trim(), + parentPath.trim(), + directoryName.trim(), + { + branch: shallowClone && branchToClone.trim() ? branchToClone.trim() : null, + blobless: sparseCheckout && bloblessClone, + customFlags: shallowClone ? customFlags.trim() : "", + shallowDepth: shallowClone && shallowLimitMode === "depth" ? shallowDepth : null, + shallowSince: shallowClone && shallowLimitMode === "since" ? shallowSince : null, + sparse: sparseCheckout, + sparsePaths: sparseCheckout ? sparsePaths : [], + }, + source === "integrations" ? activeSource?.provider : undefined, + source === "integrations" ? activeSource?.accountId : undefined, + ); } @@ -298,6 +325,48 @@ +
+
+ + {#if shallowClone} +
+ +
+ {isGerman ? "Historie begrenzen nach" : "Limit history by"} + + +
+ {#if shallowLimitMode === "depth"} + + {:else} + + {/if} + + {isGerman ? "Flags wie in der Git-Kommandozeile; verwaltete oder unsichere Flags werden abgewiesen." : "Enter flags as on the Git command line; managed or unsafe flags are rejected."} +
+ {/if} +
+
+ + {#if sparseCheckout} +
+ + +
+ {/if} +
+
+ {#if source === "url"}