feat(git-lfs): add Git LFS status and sidecar bundling

Adds Git LFS support to the backend API and related UI.
The app now exposes commands to inspect, install, track and pull.
A sidecar git-lfs binary is bundled and a prep script is added.
This prepares the correct binary for each target platform.

- Expose Git LFS status and management commands in API
- Bundle and prepare a sidecar git-lfs binary for targets
- Update packaging, docs, and README with LFS notes
This commit is contained in:
Christoph Brandau
2026-08-17 20:27:54 +02:00
parent 6f2d4dd8c9
commit cd01df0108
18 changed files with 1444 additions and 16 deletions
+629 -6
View File
@@ -186,6 +186,61 @@ pub struct GitRepositoryFile {
pub status: Option<FileStatusKind>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitLfsPattern {
pub pattern: String,
pub source: String,
#[serde(default)]
pub lockable: bool,
#[serde(default = "default_true")]
pub tracked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitLfsFile {
pub name: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub checkout: bool,
#[serde(default)]
pub downloaded: bool,
#[serde(default)]
pub oid_type: String,
#[serde(default)]
pub oid: String,
#[serde(default)]
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitLfsStatus {
pub available: bool,
pub bundled: bool,
pub version: Option<String>,
pub filters_configured: bool,
pub hook_installed: bool,
pub repository_uses_lfs: bool,
pub patterns: Vec<GitLfsPattern>,
pub files: Vec<GitLfsFile>,
}
#[derive(Debug, Default, Deserialize)]
struct GitLfsPatternsOutput {
#[serde(default)]
patterns: Vec<GitLfsPattern>,
}
#[derive(Debug, Default, Deserialize)]
struct GitLfsFilesOutput {
#[serde(default)]
files: Option<Vec<GitLfsFile>>,
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitSearchHit {
pub commit_hash: String,
@@ -333,11 +388,50 @@ fn git_command() -> Command {
// and other message heuristics keep working (e.g. German git prints
// "Authentifizierung fehlgeschlagen" instead of "Authentication failed").
command.env("LC_ALL", "C");
if let Some(path) = command_path_with_bundled_lfs() {
command.env("PATH", path);
}
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
command
}
fn bundled_git_lfs_path() -> Option<PathBuf> {
#[cfg(windows)]
const EXECUTABLE_NAME: &str = "git-lfs.exe";
#[cfg(not(windows))]
const EXECUTABLE_NAME: &str = "git-lfs";
if let Ok(executable) = env::current_exe()
&& let Some(directory) = executable.parent()
{
let candidate = directory.join(EXECUTABLE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
if cfg!(debug_assertions) {
let candidate = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("binaries")
.join(EXECUTABLE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn command_path_with_bundled_lfs() -> Option<OsString> {
let directory = bundled_git_lfs_path()?.parent()?.to_path_buf();
let mut paths = vec![directory];
if let Some(current) = env::var_os("PATH") {
paths.extend(env::split_paths(&current));
}
env::join_paths(paths).ok()
}
#[derive(Debug, Default, Clone)]
pub struct SearchCancellationState {
cancelled: Arc<Mutex<BTreeSet<String>>>,
@@ -549,6 +643,92 @@ pub async fn get_status(path: String) -> Result<GitStatus, String> {
.await
}
#[tauri::command]
pub async fn git_lfs_status(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not inspect Git LFS", move || {
let repo = resolve_repo(&path)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
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"])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_track(
path: String,
pattern: String,
lockable: Option<bool>,
) -> Result<GitLfsStatus, String> {
run_git_task("Could not add Git LFS pattern", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?;
let mut args = vec![OsString::from("lfs"), OsString::from("track")];
if lockable.unwrap_or(false) {
args.push(OsString::from("--lockable"));
}
args.push(pattern.into());
run_git(&repo, args)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_untrack(path: String, pattern: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not remove Git LFS pattern", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?;
run_git(&repo, ["lfs", "untrack", pattern.as_str()])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_pull(
path: String,
remote: Option<String>,
username: Option<String>,
password: Option<String>,
) -> Result<GitLfsStatus, String> {
run_git_task("Could not download Git LFS objects", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
pull_git_lfs_objects(
&repo,
remote.as_deref(),
username.as_deref(),
password.as_deref(),
)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_prune(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not prune Git LFS objects", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
run_git(&repo, ["lfs", "prune"])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
run_git_task("Could not load branches", move || {
@@ -2417,6 +2597,9 @@ pub async fn pull(
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let strategy = strategy.as_deref().unwrap_or("merge");
let remote = remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
@@ -2426,12 +2609,9 @@ pub async fn pull(
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote) = remote
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
validate_remote_name(&repo, &remote, true)?;
pull_args.push(remote.into());
if let Some(remote_name) = remote.as_deref() {
validate_remote_name(&repo, remote_name, true)?;
pull_args.push(remote_name.into());
if let Some(branch) = branch
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
@@ -2452,6 +2632,15 @@ 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(),
)?;
}
return status_for_repo(&repo);
}
@@ -4578,6 +4767,299 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
})
}
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 patterns = if available {
git_lfs_patterns(repo).unwrap_or_else(|_| lfs_patterns_from_attributes(repo))
} else {
lfs_patterns_from_attributes(repo)
};
let files = if available {
git_lfs_files(repo).unwrap_or_default()
} else {
Vec::new()
};
Ok(GitLfsStatus {
available,
bundled,
version,
filters_configured: lfs_filters_configured(repo),
hook_installed: lfs_hook_installed(repo),
repository_uses_lfs: !patterns.is_empty() || !files.is_empty(),
patterns,
files,
})
}
fn git_lfs_version() -> Option<String> {
let output = git_command().args(["lfs", "version"]).output().ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!version.is_empty()).then_some(version)
}
fn ensure_git_lfs_available() -> Result<String, String> {
git_lfs_version().ok_or_else(|| {
"Git LFS is unavailable. Reinstall Gitty or install git-lfs and restart the app."
.to_string()
})
}
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 pull_git_lfs_objects(
repo: &Path,
remote: Option<&str>,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let mut args = vec![OsString::from("lfs"), OsString::from("pull")];
if let Some(remote) = remote.map(str::trim).filter(|remote| !remote.is_empty()) {
args.push(validate_remote_name(repo, remote, true)?.into());
}
let output = match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated_output(repo, args, user, pass)?
}
_ => git_command()
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|err| format!("Could not start Git LFS: {err}"))?,
};
if output.status.success() {
return Ok(());
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git LFS pull failed: {details}"))
}
fn lfs_filters_configured(repo: &Path) -> bool {
[
"filter.lfs.process",
"filter.lfs.clean",
"filter.lfs.smudge",
]
.iter()
.all(|key| git_config_value(repo, key).is_some())
}
fn git_config_value(repo: &Path, key: &str) -> Option<String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["config", "--get", key])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
fn lfs_hook_installed(repo: &Path) -> bool {
let Ok(path) = run_git(repo, ["rev-parse", "--git-path", "hooks/pre-push"]) else {
return false;
};
let path = PathBuf::from(String::from_utf8_lossy(&path).trim().to_string());
let path = if path.is_absolute() {
path
} else {
repo.join(path)
};
let Ok(hook) = fs::read_to_string(path) else {
return false;
};
hook.contains("git lfs pre-push") || hook.contains("git-lfs pre-push")
}
fn git_lfs_patterns(repo: &Path) -> Result<Vec<GitLfsPattern>, String> {
let output = run_git(repo, ["lfs", "track", "--json"])?;
let mut parsed: GitLfsPatternsOutput = serde_json::from_slice(&output)
.map_err(|err| format!("Git LFS returned invalid tracked-pattern data: {err}"))?;
parsed.patterns.retain(|pattern| pattern.tracked);
parsed.patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
Ok(parsed.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 {
let parsed: GitLfsFilesOutput = serde_json::from_slice(&output)
.map_err(|err| format!("Git LFS returned invalid file data: {err}"))?;
let mut files = parsed.files.unwrap_or_default();
files.sort_by(|left, right| left.name.cmp(&right.name));
return Ok(files);
}
// Git LFS versions before JSON output was introduced still expose a stable
// line format. Size is left at zero because the human-readable --size
// suffix cannot be converted back to exact bytes reliably.
let output = run_git(repo, ["lfs", "ls-files", "--long"])?;
let mut files = String::from_utf8_lossy(&output)
.lines()
.filter_map(parse_git_lfs_file_line)
.collect::<Vec<_>>();
files.sort_by(|left, right| left.name.cmp(&right.name));
Ok(files)
}
fn parse_git_lfs_file_line(line: &str) -> Option<GitLfsFile> {
let mut fields = line.splitn(3, ' ');
let oid = fields.next()?.trim();
let marker = fields.next()?.trim();
let name = fields.next()?.trim();
if oid.is_empty() || name.is_empty() || !matches!(marker, "*" | "-") {
return None;
}
Some(GitLfsFile {
name: name.to_string(),
size: 0,
checkout: marker == "*",
downloaded: marker == "*",
oid_type: "sha256".to_string(),
oid: oid.to_string(),
version: "https://git-lfs.github.com/spec/v1".to_string(),
})
}
fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> {
let Ok(output) = run_git(
repo,
[
"ls-files",
"-z",
"--cached",
"--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;
}
read_lfs_patterns_from_file(repo, &path, &mut patterns);
}
if let Ok(info_path) = run_git(repo, ["rev-parse", "--git-path", "info/attributes"]) {
let path = PathBuf::from(String::from_utf8_lossy(&info_path).trim().to_string());
let path = if path.is_absolute() {
path
} else {
repo.join(path)
};
if let Ok(contents) = fs::read_to_string(path) {
append_lfs_patterns(&contents, ".git/info/attributes", &mut patterns);
}
}
patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
patterns.dedup();
patterns
}
fn read_lfs_patterns_from_file(
repo: &Path,
relative_path: &str,
patterns: &mut Vec<GitLfsPattern>,
) {
let Ok(contents) = fs::read_to_string(repo.join(relative_path)) else {
return;
};
append_lfs_patterns(&contents, relative_path, patterns);
}
fn append_lfs_patterns(contents: &str, source: &str, patterns: &mut Vec<GitLfsPattern>) {
for line in contents.lines() {
let Some((pattern, attributes)) = split_gitattributes_pattern(line) else {
continue;
};
let attributes = attributes.split_whitespace().collect::<Vec<_>>();
if !attributes.contains(&"filter=lfs") {
continue;
}
patterns.push(GitLfsPattern {
pattern,
source: source.replace('\\', "/"),
lockable: attributes.contains(&"lockable"),
tracked: true,
});
}
}
fn split_gitattributes_pattern(line: &str) -> Option<(String, &str)> {
let line = line.trim_start();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
return None;
}
let mut pattern = String::new();
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
pattern.push(character);
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if character.is_whitespace() {
let attributes = line[index..].trim_start();
return (!pattern.is_empty() && !attributes.is_empty())
.then_some((pattern, attributes));
}
pattern.push(character);
}
None
}
fn validate_lfs_pattern(pattern: &str) -> Result<String, String> {
let pattern = pattern.trim();
if pattern.is_empty() {
return Err("Git LFS pattern must not be empty.".to_string());
}
if pattern.len() > 1_024 {
return Err("Git LFS pattern is too long.".to_string());
}
if pattern.starts_with('-') {
return Err("Git LFS pattern must not start with '-'.".to_string());
}
if pattern.chars().any(char::is_control) {
return Err("Git LFS pattern contains invalid control characters.".to_string());
}
Ok(pattern.to_string())
}
fn rebase_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
}
@@ -6452,6 +6934,147 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]);
}
#[test]
fn parses_git_lfs_json_status_records() {
let patterns: GitLfsPatternsOutput = serde_json::from_str(
r#"{"patterns":[{"pattern":"*.psd","source":".gitattributes","lockable":true,"tracked":true}]}"#,
)
.expect("pattern JSON should parse");
assert_eq!(patterns.patterns.len(), 1);
assert_eq!(patterns.patterns[0].pattern, "*.psd");
assert!(patterns.patterns[0].lockable);
let files: GitLfsFilesOutput = serde_json::from_str(
r#"{"files":[{"name":"Assets/scene.psd","size":2048,"checkout":false,"downloaded":true,"oid_type":"sha256","oid":"abc","version":"https://git-lfs.github.com/spec/v1"}]}"#,
)
.expect("file JSON should parse");
let file = files
.files
.expect("files should be present")
.pop()
.expect("one file should be present");
assert_eq!(file.name, "Assets/scene.psd");
assert_eq!(file.size, 2048);
assert!(file.downloaded);
}
#[test]
fn parses_lfs_attributes_and_plain_file_output() {
assert_eq!(
split_gitattributes_pattern(r"Assets/My\ Files/** filter=lfs diff=lfs -text"),
Some((
"Assets/My Files/**".to_string(),
"filter=lfs diff=lfs -text"
))
);
assert_eq!(split_gitattributes_pattern("# *.zip filter=lfs"), None);
let file = parse_git_lfs_file_line(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - Assets/My File.bin",
)
.expect("plain LFS record should parse");
assert_eq!(file.name, "Assets/My File.bin");
assert!(!file.downloaded);
assert!(!file.checkout);
}
#[test]
fn validates_git_lfs_patterns() {
assert_eq!(validate_lfs_pattern(" *.psd ").unwrap(), "*.psd");
assert!(validate_lfs_pattern("").is_err());
assert!(validate_lfs_pattern("--include=*").is_err());
assert!(validate_lfs_pattern("*.bin\n*.zip").is_err());
}
#[test]
fn detects_git_lfs_repository_from_attributes() {
let repo = init_temp_repo("lfs_repository_detection");
assert!(!repository_uses_lfs(&repo.path));
fs::write(
repo.path.join(".gitattributes"),
"*.bin filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
assert!(repository_uses_lfs(&repo.path));
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail LFS filter tests with a sh signal pipe error"
)]
fn pulls_git_lfs_objects_from_local_remote() {
ensure_git_lfs_available().expect("Git LFS should be available for the test");
let source = init_temp_repo("lfs_pull_source");
let remote = init_bare_temp_repo("lfs_pull_remote");
let checkout = temp_dir("lfs_pull_checkout");
run_git_test(&source.path, ["lfs", "install", "--local"]);
run_git_test(&source.path, ["lfs", "track", "*.bin"]);
fs::write(source.path.join("asset.bin"), b"downloaded 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]);
let remote_head = format!("refs/heads/{branch}");
run_git_test(&remote.path, ["symbolic-ref", "HEAD", &remote_head]);
let output = git_command()
.arg("clone")
.arg("-q")
.arg(&remote.path)
.arg(&checkout.path)
.env("GIT_LFS_SKIP_SMUDGE", "1")
.output()
.expect("Git clone should start");
assert!(
output.status.success(),
"Git clone failed: {}",
command_output_details(&output)
);
let pointer = fs::read_to_string(checkout.path.join("asset.bin"))
.expect("skipped LFS object should remain a pointer");
assert!(pointer.starts_with("version https://git-lfs.github.com/spec/v1"));
assert!(repository_uses_lfs(&checkout.path));
pull_git_lfs_objects(&checkout.path, Some("origin"), None, None)
.expect("automatic LFS pull should succeed");
assert_eq!(
fs::read(checkout.path.join("asset.bin")).expect("LFS object should be downloaded"),
b"downloaded LFS payload\n"
);
}
#[test]
fn git_lfs_status_finds_the_bundled_extension() {
let repo = init_temp_repo("bundled_lfs_status");
let status = git_lfs_status_for_repo(&repo.path).expect("LFS status should load");
assert!(status.available);
assert!(status.bundled);
assert!(
status
.version
.as_deref()
.is_some_and(|version| version.starts_with("git-lfs/"))
);
assert!(!status.repository_uses_lfs);
assert!(status.patterns.is_empty());
assert!(status.files.is_empty());
}
#[test]
fn branches_report_configured_upstream_and_local_only_state() {
let repo = init_temp_repo("branch_upstream_state");
+8 -1
View File
@@ -17,7 +17,8 @@ use git::{
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install,
git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
@@ -216,6 +217,12 @@ async fn main() {
launch_external_diff,
launch_external_merge,
get_status,
git_lfs_status,
git_lfs_install,
git_lfs_track,
git_lfs_untrack,
git_lfs_pull,
git_lfs_prune,
list_branches,
list_remotes,
add_remote,