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
+48
View File
@@ -0,0 +1,48 @@
MIT License
Copyright (c) 2014- GitHub, Inc. and Git LFS contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Portions of the subprocess and tools directories are copied from Go and are
under the following license:
Copyright (c) 2009,2010 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of Google Inc. nor the names of its contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+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,
+8 -2
View File
@@ -4,9 +4,9 @@
"version": "2026.8.4",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"beforeBuildCommand": "npm run prepare:lfs && npm run build",
"frontendDist": "../dist"
},
"app": {
@@ -41,6 +41,12 @@
},
"bundle": {
"active": true,
"externalBin": [
"binaries/git-lfs"
],
"resources": [
"binaries/git-lfs-LICENSE.txt"
],
"targets": [
"nsis"
],