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:
Generated
+1
@@ -1478,6 +1478,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user