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:
+13
-2
@@ -32,8 +32,19 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||
|
||||
### Changed
|
||||
|
||||
- A successful Git pull now detects repositories that use LFS and downloads
|
||||
the required LFS objects automatically with the same remote and credentials.
|
||||
- Successful clones and pulls now detect repositories that use LFS and
|
||||
download the required LFS objects automatically with the same remote and
|
||||
credentials. Fresh clones also activate LFS locally before they are opened.
|
||||
- Activating LFS or adding a tracking pattern now ensures that a root
|
||||
`.gitattributes` file is not hidden by Git ignore rules. When necessary,
|
||||
Gitty adds the scoped `!/.gitattributes` exception to `.gitignore`.
|
||||
- The standard Tauri development launcher now removes an injected non-routing
|
||||
`127.0.0.1:9` proxy and blocking SSH placeholder from the debug child
|
||||
process, while preserving real user and company proxy settings.
|
||||
- Git LFS pushes rejected with HTTP 413 are retried once with a command-scoped
|
||||
HTTP/1.1 override, which works around Azure DevOps' large HTTP/2 upload
|
||||
behavior without changing repository or global Git settings. LFS and other
|
||||
generic push failures no longer trigger the unrelated Pull/Push retry flow.
|
||||
- Unstaged and staged changes use an equal-width side-by-side layout with
|
||||
independent scrolling, directional stage/unstage actions, and a responsive
|
||||
vertical fallback. The List/Tree switch is centered above both areas.
|
||||
|
||||
@@ -103,17 +103,31 @@ the running window when Gitty is already open.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Start the complete desktop application in development mode with:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
The development launcher preserves normal proxy settings. If a sandboxed
|
||||
development terminal injects the non-routing `127.0.0.1:9` proxy or its
|
||||
blocking SSH placeholder, those values are removed only for the Tauri child
|
||||
process so Git remotes and Git LFS remain testable in the debug application.
|
||||
|
||||
---
|
||||
|
||||
## Git LFS
|
||||
|
||||
Gitty bundles the `git-lfs` executable in its desktop installers and checks it
|
||||
at runtime before offering LFS actions. Arch packages also declare `git-lfs` as
|
||||
a dependency so Git hooks and command-line workflows outside Gitty use the same
|
||||
extension. The repository toolbar exposes LFS setup, tracked patterns, object
|
||||
downloads, and safe cache pruning. Normal clone, pull, checkout, and push
|
||||
operations continue to use Git's standard LFS filters and pre-push hook. After
|
||||
every successful pull, Gitty detects LFS usage and automatically downloads the
|
||||
required LFS objects with the same remote and credentials, so no second pull is
|
||||
needed.
|
||||
downloads, and safe cache pruning. After every successful clone or pull, Gitty
|
||||
detects LFS usage and automatically downloads the required LFS objects with the
|
||||
same remote and credentials, so no second pull is needed. Fresh clones also get
|
||||
repository-local LFS filters and the pre-push hook before they are opened.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"icons": "node scripts/generate-app-icons.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:dev": "node scripts/tauri-dev.mjs",
|
||||
"tauri:build": "tauri build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const debugEnvironment = { ...process.env };
|
||||
const clearedVariables = [];
|
||||
const blockedProxy = /^https?:\/\/127\.0\.0\.1:9\/?$/i;
|
||||
const proxyVariables = new Set([
|
||||
"all_proxy",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"git_http_proxy",
|
||||
"git_https_proxy",
|
||||
]);
|
||||
|
||||
for (const [name, value] of Object.entries(debugEnvironment)) {
|
||||
if (proxyVariables.has(name.toLowerCase()) && blockedProxy.test(value ?? "")) {
|
||||
delete debugEnvironment[name];
|
||||
clearedVariables.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(debugEnvironment)) {
|
||||
if (
|
||||
name.toLowerCase() === "git_ssh_command"
|
||||
&& /^cmd(?:\.exe)?\s+\/c\s+exit\s+1$/i.test((value ?? "").trim())
|
||||
) {
|
||||
delete debugEnvironment[name];
|
||||
clearedVariables.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes("--check-environment")) {
|
||||
process.stdout.write(
|
||||
clearedVariables.length > 0
|
||||
? `Debug environment ready; cleared: ${clearedVariables.sort().join(", ")}\n`
|
||||
: "Debug environment ready; no blocked proxy variables found.\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (clearedVariables.length > 0) {
|
||||
process.stdout.write(
|
||||
`Starting Gitty without the blocked debug proxy (${clearedVariables.sort().join(", ")}).\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const child = spawn(isWindows ? "tauri.cmd" : "tauri", ["dev"], {
|
||||
cwd: process.cwd(),
|
||||
env: debugEnvironment,
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`Could not start Tauri development mode: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
process.exitCode = code ?? 1;
|
||||
});
|
||||
+419
-34
@@ -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 =
|
||||
|
||||
+2
-1
@@ -2078,7 +2078,6 @@
|
||||
function isNonFastForwardPushError(message: string): boolean {
|
||||
const value = message.toLowerCase();
|
||||
return value.includes("non-fast-forward")
|
||||
|| value.includes("failed to push some refs")
|
||||
|| value.includes("tip of your current branch is behind")
|
||||
|| value.includes("fetch first");
|
||||
}
|
||||
@@ -2424,6 +2423,7 @@
|
||||
activeView = "repository";
|
||||
cloneDialogOpen = false;
|
||||
pendingClone = null;
|
||||
errorMessage = bundle.warning ?? "";
|
||||
if (credDialogAction === "clone") {
|
||||
credDialogOpen = false;
|
||||
credDialogAction = null;
|
||||
@@ -2434,6 +2434,7 @@
|
||||
trackEvent("repository_cloned", {
|
||||
changed_files: bundle.status.files.length,
|
||||
has_upstream: bundle.status.upstream ? 1 : 0,
|
||||
lfs_warning: bundle.warning ? 1 : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const rawMessage = errorToMessage(error);
|
||||
|
||||
@@ -503,8 +503,9 @@
|
||||
{
|
||||
id: "lfs-sync",
|
||||
title: "LFS-Objekte synchronisieren",
|
||||
summary: "Ein normaler Pull in Gitty prüft nach erfolgreicher Git-Synchronisierung automatisch auf LFS und lädt benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
|
||||
summary: "Clone und Pull prüfen in Gitty automatisch auf LFS und laden benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
|
||||
steps: [
|
||||
"Neue Klone aus der Oberfläche sowie über --clone aktivieren LFS lokal und laden die Objekte von origin, bevor das Repository geöffnet wird.",
|
||||
"Normales Push nutzt den LFS-Pre-push-Hook und lädt neue LFS-Objekte vor den Git-Referenzen hoch.",
|
||||
"Objekte laden im LFS-Dialog ist ein manueller Reparatur- oder Aktualisierungsschritt, falls lokale Inhalte fehlen.",
|
||||
"Cache bereinigen entfernt sicher nicht mehr benötigte lokale Objekte; aktuell verwendete und noch nicht gepushte Inhalte bleiben erhalten.",
|
||||
@@ -563,8 +564,9 @@
|
||||
{
|
||||
id: "lfs-sync",
|
||||
title: "Synchronize LFS objects",
|
||||
summary: "After a successful regular pull, Gitty automatically checks for LFS and downloads required objects with the same remote and credentials. A second pull is not necessary.",
|
||||
summary: "Clone and pull automatically check for LFS in Gitty and download required objects with the same remote and credentials. A second pull is not necessary.",
|
||||
steps: [
|
||||
"Fresh clones from the UI and --clone activate LFS locally and download objects from origin before the repository is opened.",
|
||||
"A normal push uses the LFS pre-push hook to upload new LFS objects before Git references are published.",
|
||||
"Pull objects in the LFS dialog is a manual repair or refresh action when local content is missing.",
|
||||
"Prune cache safely removes unused local objects while retaining current and unpushed content.",
|
||||
@@ -1527,7 +1529,7 @@
|
||||
steps: [
|
||||
"Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.",
|
||||
"LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.",
|
||||
"Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.",
|
||||
"Nach einem erfolgreichen Clone oder Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Neue Klone aktivieren LFS außerdem lokal, sodass kein zweiter manueller Pull erforderlich ist.",
|
||||
"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.",
|
||||
"Ü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.",
|
||||
@@ -1643,7 +1645,7 @@
|
||||
steps: [
|
||||
"Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.",
|
||||
"LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.",
|
||||
"After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.",
|
||||
"After a successful clone or pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. Fresh clones also activate LFS locally, so a second manual pull is no longer required.",
|
||||
"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 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.",
|
||||
|
||||
@@ -230,6 +230,7 @@ export interface RepositoryBundle {
|
||||
stashes: GitStash[];
|
||||
commits: GitCommit[];
|
||||
files: GitRepositoryFile[];
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
|
||||
Reference in New Issue
Block a user