feat(git-lfs): improve activation and add HTTP/1.1 retry

The change adds a safer LFS activation flow that ensures a
root .gitattributes file is not hidden by ignore rules and
activates local LFS filters. It also merges patterns from the
repository attributes with those reported by Git LFS to avoid
duplicates and inaccuracies.

- Adds a retry path for large LFS uploads by forcing HTTP/1.1
during pushes when an HTTP 413 error is returned.
This commit is contained in:
Christoph Brandau
2026-08-18 21:36:13 +02:00
parent 7408371430
commit 6d806e87ea
8 changed files with 522 additions and 47 deletions
+419 -34
View File
@@ -551,6 +551,7 @@ pub struct RepositoryBundle {
pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>,
pub warning: Option<String>,
}
async fn run_git_task<T, F>(context: &'static str, task: F) -> Result<T, String>
@@ -600,6 +601,7 @@ fn repository_bundle_for_repo(
stashes,
commits,
files,
warning: None,
})
}
@@ -665,8 +667,7 @@ pub async fn git_lfs_status(path: String) -> Result<GitLfsStatus, String> {
pub async fn git_lfs_install(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not activate Git LFS", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
run_git(&repo, ["lfs", "install", "--local"])?;
activate_git_lfs_for_repo(&repo)?;
git_lfs_status_for_repo(&repo)
})
.await
@@ -682,6 +683,7 @@ pub async fn git_lfs_track(
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?;
ensure_root_gitattributes_not_ignored(&repo)?;
let mut args = vec![OsString::from("lfs"), OsString::from("track")];
if lockable.unwrap_or(false) {
args.push(OsString::from("--lockable"));
@@ -2685,15 +2687,13 @@ pub async fn pull(
};
if output.status.success() {
if repository_uses_lfs(&repo) {
ensure_git_lfs_available()?;
pull_git_lfs_objects(
&repo,
remote.as_deref(),
username.as_deref(),
password.as_deref(),
)?;
}
sync_git_lfs_objects_if_needed(
&repo,
remote.as_deref(),
username.as_deref(),
password.as_deref(),
false,
)?;
return status_for_repo(&repo);
}
@@ -2767,24 +2767,89 @@ pub async fn push(
force_with_lease: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let result = tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?;
if force_with_lease.unwrap_or(false) {
push_args.insert(1, OsString::from("--force-with-lease"));
}
match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?;
}
_ => {
run_git(&repo, push_args)?;
if let Err(error) = run_push_command(
&repo,
push_args.clone(),
username.as_deref(),
password.as_deref(),
) {
if !is_lfs_http_413_error(&error) {
return Err(error);
}
// Azure DevOps can reject LFS objects above roughly 128 MB when
// HTTP/2 sends them with one large Content-Length. Git LFS can
// transfer the same object over HTTP/1.1 using chunked encoding.
// Keep this override scoped to the retry instead of changing the
// user's repository or global Git configuration.
log::warn!(
target: "gitty::remote",
"Git LFS upload returned HTTP 413; retrying push with HTTP/1.1"
);
run_push_command(
&repo,
push_args_with_http_1_1(push_args),
username.as_deref(),
password.as_deref(),
)
.map_err(|retry_error| {
format!(
"Git LFS upload was rejected with HTTP 413 even after retrying with HTTP/1.1: {retry_error}"
)
})?;
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not push: {err}"))?
.map_err(|err| format!("Could not push: {err}"))?;
match &result {
Ok(_) => log::info!(target: "gitty::remote", "push completed successfully"),
Err(error) => log::error!(target: "gitty::remote", "push failed: {error}"),
}
result
}
fn run_push_command(
repo: &Path,
args: Vec<OsString>,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, args, user, pass)?;
}
_ => {
run_git(repo, args)?;
}
}
Ok(())
}
fn is_lfs_http_413_error(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("lfs:")
&& (error.contains("http 413")
|| error.contains("413 content too large")
|| error.contains("413 payload too large")
|| error.contains("413 request entity too large"))
}
fn push_args_with_http_1_1(push_args: Vec<OsString>) -> Vec<OsString> {
let mut retry_args = vec![
OsString::from("-c"),
OsString::from("http.version=HTTP/1.1"),
];
retry_args.extend(push_args);
retry_args
}
// ── Credential storage (OS keychain) ────────────────────────────────────────
@@ -4824,10 +4889,14 @@ fn git_lfs_status_for_repo(repo: &Path) -> Result<GitLfsStatus, String> {
let bundled = bundled_git_lfs_path().is_some();
let version = git_lfs_version();
let available = version.is_some();
let attribute_patterns = lfs_patterns_from_attributes(repo);
let patterns = if available {
git_lfs_patterns(repo).unwrap_or_else(|_| lfs_patterns_from_attributes(repo))
match git_lfs_patterns(repo) {
Ok(reported_patterns) => merge_lfs_patterns(attribute_patterns, reported_patterns),
Err(_) => attribute_patterns,
}
} else {
lfs_patterns_from_attributes(repo)
attribute_patterns
};
let files = if available {
git_lfs_files(repo).unwrap_or_default()
@@ -4863,11 +4932,75 @@ fn ensure_git_lfs_available() -> Result<String, String> {
})
}
fn activate_git_lfs_for_repo(repo: &Path) -> Result<(), String> {
ensure_git_lfs_available()?;
run_git(repo, ["lfs", "install", "--local"])?;
ensure_root_gitattributes_not_ignored(repo)?;
Ok(())
}
fn git_path_is_ignored(repo: &Path, path: &str) -> Result<bool, String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["check-ignore", "--quiet", "--no-index", "--", path])
.output()
.map_err(|err| format!("Could not inspect Git ignore rules: {err}"))?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(format!(
"Could not inspect Git ignore rules: {}",
command_output_details(&output)
)),
}
}
fn ensure_root_gitattributes_not_ignored(repo: &Path) -> Result<bool, String> {
const GITATTRIBUTES_PATH: &str = ".gitattributes";
const GITATTRIBUTES_EXCEPTION: &str = "!/.gitattributes";
if !git_path_is_ignored(repo, GITATTRIBUTES_PATH)? {
return Ok(false);
}
// The last matching ignore rule wins. Always append here, even if an
// earlier exception exists and is overridden by a later ignore rule.
append_gitignore_pattern_to_file(repo, GITATTRIBUTES_EXCEPTION, false)?;
if git_path_is_ignored(repo, GITATTRIBUTES_PATH)? {
return Err(
"`.gitattributes` is still ignored. Check higher-priority Git ignore rules."
.to_string(),
);
}
Ok(true)
}
fn repository_uses_lfs(repo: &Path) -> bool {
!lfs_patterns_from_attributes(repo).is_empty()
|| git_lfs_files(repo).is_ok_and(|files| !files.is_empty())
}
fn sync_git_lfs_objects_if_needed(
repo: &Path,
remote: Option<&str>,
username: Option<&str>,
password: Option<&str>,
install_local: bool,
) -> Result<(), String> {
if !repository_uses_lfs(repo) {
return Ok(());
}
if install_local {
activate_git_lfs_for_repo(repo)?;
} else {
ensure_git_lfs_available()?;
}
pull_git_lfs_objects(repo, remote, username, password)
}
fn pull_git_lfs_objects(
repo: &Path,
remote: Option<&str>,
@@ -4956,6 +5089,29 @@ fn git_lfs_patterns(repo: &Path) -> Result<Vec<GitLfsPattern>, String> {
Ok(parsed.patterns)
}
fn merge_lfs_patterns(
mut attribute_patterns: Vec<GitLfsPattern>,
reported_patterns: Vec<GitLfsPattern>,
) -> Vec<GitLfsPattern> {
for reported in reported_patterns {
if let Some(existing) = attribute_patterns.iter_mut().find(|existing| {
existing.source == reported.source && existing.pattern == reported.pattern
}) {
*existing = reported;
} else {
attribute_patterns.push(reported);
}
}
attribute_patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
attribute_patterns
.dedup_by(|left, right| left.source == right.source && left.pattern == right.pattern);
attribute_patterns
}
fn git_lfs_files(repo: &Path) -> Result<Vec<GitLfsFile>, String> {
let json_output = run_git(repo, ["lfs", "ls-files", "--long", "--size", "--json"]);
if let Ok(output) = json_output {
@@ -4998,7 +5154,12 @@ fn parse_git_lfs_file_line(line: &str) -> Option<GitLfsFile> {
}
fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> {
let Ok(output) = run_git(
let mut patterns = Vec::new();
// Read the repository root explicitly. `git lfs track` writes here even
// when .gitattributes is still untracked or excluded by an ignore rule.
read_lfs_patterns_from_file(repo, ".gitattributes", &mut patterns);
if let Ok(output) = run_git(
repo,
[
"ls-files",
@@ -5007,15 +5168,15 @@ fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> {
"--others",
"--exclude-standard",
],
) else {
return Vec::new();
};
let mut patterns = Vec::new();
for path in parse_nul_paths(&output) {
if Path::new(&path).file_name() != Some(OsStr::new(".gitattributes")) {
continue;
) {
for path in parse_nul_paths(&output) {
if path.replace('\\', "/") == ".gitattributes"
|| Path::new(&path).file_name() != Some(OsStr::new(".gitattributes"))
{
continue;
}
read_lfs_patterns_from_file(repo, &path, &mut patterns);
}
read_lfs_patterns_from_file(repo, &path, &mut patterns);
}
if let Ok(info_path) = run_git(repo, ["rev-parse", "--git-path", "info/attributes"]) {
@@ -5312,7 +5473,23 @@ fn clone_repository_core(
run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?;
repository_bundle_for_repo(&repo, commit_limit)
let lfs_warning = sync_git_lfs_objects_if_needed(
&repo,
Some("origin"),
username,
password,
true,
)
.err()
.map(|error| {
format!(
"Repository cloned, but Git LFS objects could not be downloaded automatically: {error}"
)
});
let mut bundle = repository_bundle_for_repo(&repo, commit_limit)?;
bundle.warning = lfs_warning;
Ok(bundle)
}
fn clone_target_path(
@@ -6417,6 +6594,14 @@ fn gitignore_target_matches(kind: GitIgnoreKind, target: &str, candidate: &str)
}
fn append_gitignore_pattern(repo: &Path, pattern: &str) -> Result<bool, String> {
append_gitignore_pattern_to_file(repo, pattern, true)
}
fn append_gitignore_pattern_to_file(
repo: &Path,
pattern: &str,
skip_if_present: bool,
) -> Result<bool, String> {
let gitignore = repo.join(".gitignore");
match fs::symlink_metadata(&gitignore) {
Ok(metadata) if metadata.file_type().is_symlink() => {
@@ -6437,9 +6622,10 @@ fn append_gitignore_pattern(repo: &Path, pattern: &str) -> Result<bool, String>
};
let text = std::str::from_utf8(&existing)
.map_err(|_| ".gitignore is not valid UTF-8 and cannot be updated safely.".to_string())?;
if text
.lines()
.any(|line| line.trim_end_matches('\r') == pattern)
if skip_if_present
&& text
.lines()
.any(|line| line.trim_end_matches('\r') == pattern)
{
return Ok(false);
}
@@ -7189,6 +7375,91 @@ mod tests {
assert!(!file.checkout);
}
#[test]
fn merges_attribute_patterns_when_git_lfs_reports_none() {
let patterns = merge_lfs_patterns(
vec![GitLfsPattern {
pattern: "*.prt".to_string(),
source: ".gitattributes".to_string(),
lockable: false,
tracked: true,
}],
Vec::new(),
);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pattern, "*.prt");
}
#[test]
fn reads_ignored_root_gitattributes_for_lfs_patterns() {
let repo = init_temp_repo("ignored_root_lfs_attributes");
fs::write(repo.path.join(".gitignore"), ".gitattributes\n")
.expect("ignore fixture should be written");
fs::write(
repo.path.join(".gitattributes"),
"*.prt filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
let patterns = lfs_patterns_from_attributes(&repo.path);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pattern, "*.prt");
assert_eq!(patterns[0].source, ".gitattributes");
}
#[test]
fn lfs_activation_unignores_root_gitattributes() {
let repo = init_temp_repo("unignore_lfs_attributes");
fs::write(repo.path.join(".gitignore"), ".gitattributes\n")
.expect("ignore fixture should be written");
assert!(
ensure_root_gitattributes_not_ignored(&repo.path)
.expect("LFS activation should unignore .gitattributes")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
".gitattributes\n!/.gitattributes\n"
);
assert!(
!git_path_is_ignored(&repo.path, ".gitattributes")
.expect("ignore state should be readable")
);
assert!(
!ensure_root_gitattributes_not_ignored(&repo.path)
.expect("the existing exception should remain unchanged")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
".gitattributes\n!/.gitattributes\n"
);
}
#[test]
fn lfs_activation_moves_an_overridden_gitattributes_exception_to_the_end() {
let repo = init_temp_repo("refresh_lfs_attributes_exception");
fs::write(
repo.path.join(".gitignore"),
"!/.gitattributes\n.gitattributes\n",
)
.expect("ignore fixture should be written");
assert!(
ensure_root_gitattributes_not_ignored(&repo.path)
.expect("LFS activation should restore the final exception")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"!/.gitattributes\n.gitattributes\n!/.gitattributes\n"
);
assert!(
!git_path_is_ignored(&repo.path, ".gitattributes")
.expect("ignore state should be readable")
);
}
#[test]
fn validates_git_lfs_patterns() {
assert_eq!(validate_lfs_pattern(" *.psd ").unwrap(), "*.psd");
@@ -7211,6 +7482,30 @@ mod tests {
assert!(repository_uses_lfs(&repo.path));
}
#[test]
fn clone_lfs_sync_installs_local_filters_before_download() {
let repo = init_temp_repo("clone_lfs_local_setup");
fs::write(
repo.path.join(".gitattributes"),
"*.bin filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
// A repository without a remote may reject the pull, but clone setup
// must already be complete before that network step is attempted.
let _ = sync_git_lfs_objects_if_needed(&repo.path, None, None, None, true);
assert!(
run_git(
&repo.path,
["config", "--local", "--get", "filter.lfs.process"]
)
.is_ok(),
"clone sync should configure repository-local LFS filters"
);
assert!(lfs_hook_installed(&repo.path));
}
#[test]
#[cfg_attr(
windows,
@@ -7515,6 +7810,66 @@ mod tests {
assert!(bundle.status.clean);
assert_eq!(bundle.commits.len(), 1);
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
assert!(bundle.warning.is_none());
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local LFS clone tests with a sh signal pipe error"
)]
fn clone_repository_core_activates_and_downloads_lfs() {
let source = init_temp_repo("lfs_clone_source");
let remote = init_bare_temp_repo("lfs_clone_remote");
let parent = temp_dir("lfs_clone_parent");
run_git_test(&source.path, ["lfs", "install", "--local"]);
run_git_test(&source.path, ["lfs", "track", "*.bin"]);
fs::write(source.path.join("asset.bin"), b"cloned LFS payload\n")
.expect("LFS fixture should be written");
run_git_test(&source.path, ["add", ".gitattributes", "asset.bin"]);
run_git_test(&source.path, ["commit", "-q", "-m", "add LFS object"]);
run_git_test(
&source.path,
[
OsString::from("remote"),
OsString::from("add"),
OsString::from("origin"),
remote.path.as_os_str().to_owned(),
],
);
let branch = git_output_test(&source.path, ["branch", "--show-current"]);
run_git_test(&source.path, ["push", "-q", "-u", "origin", &branch]);
run_git_test(
&remote.path,
["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
);
let bundle = clone_repository_core(
remote.path.to_str().expect("remote path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("LFS repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert!(bundle.warning.is_none());
assert_eq!(
fs::read(cloned_repo.join("asset.bin")).expect("LFS object should be downloaded"),
b"cloned LFS payload\n"
);
assert!(
run_git(
&cloned_repo,
["config", "--local", "--get", "filter.lfs.process"]
)
.is_ok(),
"clone should configure repository-local LFS filters"
);
assert!(lfs_hook_installed(&cloned_repo));
}
#[test]
@@ -8249,6 +8604,36 @@ mod tests {
);
}
#[test]
fn detects_lfs_http_413_and_builds_scoped_http_1_1_retry() {
assert!(is_lfs_http_413_error(
"Git command failed: LFS: Client error https://example.test/object from HTTP 413\nerror: failed to push some refs"
));
assert!(!is_lfs_http_413_error(
"error: failed to push some refs (fetch first)"
));
let retry_args = push_args_with_http_1_1(vec![
OsString::from("push"),
OsString::from("--force-with-lease"),
OsString::from("origin"),
])
.into_iter()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(
retry_args,
vec![
"-c",
"http.version=HTTP/1.1",
"push",
"--force-with-lease",
"origin"
]
);
}
#[test]
fn credential_payload_remains_backward_compatible() {
let legacy: StoredCredential =