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
This commit is contained in:
2026-08-31 19:43:30 +02:00
parent 4c58b14691
commit 90df347e4a
7 changed files with 455 additions and 15 deletions
+1
View File
@@ -1478,6 +1478,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"shlex",
"sysinfo",
"tauri",
"tauri-build",
+1
View File
@@ -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 = [] }
+325 -4
View File
@@ -632,8 +632,24 @@ pub async fn clone_repository(
username: Option<String>,
password: Option<String>,
commit_limit: Option<u32>,
branch: Option<String>,
blobless: Option<bool>,
custom_flags: Option<String>,
shallow_depth: Option<u32>,
shallow_since: Option<String>,
sparse: Option<bool>,
sparse_paths: Option<Vec<String>>,
) -> Result<RepositoryBundle, String> {
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<Vec<GitRepositoryFile>, String> {
let mut files = BTreeMap::<String, GitRepositoryFile>::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<String>,
blobless: bool,
custom_flags: String,
shallow_depth: Option<u32>,
shallow_since: Option<String>,
sparse: bool,
sparse_paths: Vec<String>,
}
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<u32>,
clone_options: &CloneRunOptions,
) -> Result<RepositoryBundle, String> {
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<Option<String>, 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<Option<String>, 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<Vec<String>, 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<Vec<String>, 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<bool, String> {
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");