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:
@@ -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
@@ -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(¤t));
|
||||
}
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user