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:
@@ -161,7 +161,7 @@ jobs:
|
||||
>> /etc/pacman.d/mirrorlist
|
||||
|
||||
ARCH_PACKAGES=(
|
||||
base-devel curl git nodejs npm openssh rust
|
||||
base-devel curl git git-lfs nodejs npm openssh rust
|
||||
webkit2gtk-4.1 gtk3 hicolor-icon-theme
|
||||
libappindicator-gtk3 librsvg xdotool
|
||||
)
|
||||
@@ -486,7 +486,7 @@ jobs:
|
||||
- name: Install Ubuntu dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
sudo apt-get install -y build-essential curl wget file git-lfs libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/src-tauri/target
|
||||
/src-tauri/binaries/git-lfs
|
||||
/src-tauri/binaries/git-lfs.exe
|
||||
/src-tauri/binaries/git-lfs-*-*-*
|
||||
*.log
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
@@ -7,7 +7,7 @@ pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||
arch=('x86_64')
|
||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||
license=('MIT')
|
||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'git-lfs' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
makedepends=('rust' 'nodejs' 'npm')
|
||||
options=('!lto' '!debug')
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch packag
|
||||
arch=('x86_64')
|
||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||
license=('MIT')
|
||||
depends=('git' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
depends=('git' 'git-lfs' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
provides=('gitty-desktop')
|
||||
conflicts=('gitty-desktop')
|
||||
options=('!strip')
|
||||
|
||||
@@ -20,6 +20,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
|
||||
- 🔄 Pull, Push & Fetch
|
||||
- 🔀 Merge & Rebase
|
||||
- 📦 Repository management
|
||||
- 🗄️ Git LFS detection, tracking and object management
|
||||
- 🎨 Modern and intuitive UI
|
||||
|
||||
---
|
||||
@@ -81,6 +82,20 @@ files.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## SigNoz telemetry
|
||||
|
||||
After the user opts in, Gitty sends privacy-filtered product events and technical errors as OTLP/HTTP JSON logs. Every Git/Tauri command is also captured as a trace span with its command name, duration and success state, but without command arguments or results. CPU utilization and resident memory usage of the Gitty process are sampled every 30 seconds and exported as OpenTelemetry gauges. The default endpoints are `https://telemetry.cbsk-tech.de/v1/logs`, `https://telemetry.cbsk-tech.de/v1/traces`, and `https://telemetry.cbsk-tech.de/v1/metrics`.
|
||||
|
||||
@@ -73,6 +73,34 @@ interface GitRepositoryFile {
|
||||
tracked: boolean;
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
interface GitLfsStatus {
|
||||
available: boolean;
|
||||
bundled: boolean;
|
||||
version: string | null;
|
||||
filters_configured: boolean;
|
||||
hook_installed: boolean;
|
||||
repository_uses_lfs: boolean;
|
||||
patterns: GitLfsPattern[];
|
||||
files: GitLfsFile[];
|
||||
}
|
||||
|
||||
interface GitLfsPattern {
|
||||
pattern: string;
|
||||
source: string;
|
||||
lockable: boolean;
|
||||
tracked: boolean;
|
||||
}
|
||||
|
||||
interface GitLfsFile {
|
||||
name: string;
|
||||
size: number;
|
||||
checkout: boolean;
|
||||
downloaded: boolean;
|
||||
oid_type: string;
|
||||
oid: string;
|
||||
version: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -83,6 +111,14 @@ The command list below includes the repository-management and synchronization AP
|
||||
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||
- `get_status(path: string): Promise<GitStatus>`
|
||||
- `git_lfs_status(path: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_install(path: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_track(path: string, pattern: string, lockable?: boolean): Promise<GitLfsStatus>`
|
||||
- `git_lfs_untrack(path: string, pattern: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_pull(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_prune(path: string): Promise<GitLfsStatus>`
|
||||
- `pull(...)` automatically runs `git lfs pull` after a successful Git pull when
|
||||
the repository contains LFS attributes or tracked LFS objects.
|
||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||
- `list_remotes(path: string): Promise<GitRemote[]>`
|
||||
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"prepare:lfs": "node scripts/prepare-git-lfs-sidecar.mjs",
|
||||
"icons": "node scripts/generate-app-icons.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "tauri",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, chmodSync, statSync } from "node:fs";
|
||||
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(scriptDirectory, "..");
|
||||
const binariesDirectory = join(projectRoot, "src-tauri", "binaries");
|
||||
const extension = process.platform === "win32" ? ".exe" : "";
|
||||
|
||||
function defaultTargetTriple() {
|
||||
const triples = {
|
||||
"win32:x64": "x86_64-pc-windows-msvc",
|
||||
"win32:arm64": "aarch64-pc-windows-msvc",
|
||||
"linux:x64": "x86_64-unknown-linux-gnu",
|
||||
"linux:arm64": "aarch64-unknown-linux-gnu",
|
||||
"darwin:x64": "x86_64-apple-darwin",
|
||||
"darwin:arm64": "aarch64-apple-darwin",
|
||||
};
|
||||
return triples[`${process.platform}:${process.arch}`];
|
||||
}
|
||||
|
||||
function executableCandidates() {
|
||||
const configured = process.env.GIT_LFS_BINARY?.trim();
|
||||
if (configured) return [configured];
|
||||
|
||||
const pathEntries = (process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.filter(Boolean);
|
||||
const candidates = pathEntries.map((entry) => join(entry, `git-lfs${extension}`));
|
||||
if (process.platform === "win32") {
|
||||
for (const entry of pathEntries) {
|
||||
if (basename(entry).toLowerCase() === "cmd") {
|
||||
candidates.push(resolve(entry, "..", "mingw64", "bin", "git-lfs.exe"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const source = executableCandidates()
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
// Git for Windows exposes a small launcher in cmd/ and the standalone
|
||||
// executable in mingw64/bin/. The standalone file is the portable sidecar.
|
||||
.sort((left, right) => statSync(right).size - statSync(left).size)[0];
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"Git LFS was not found. Install git-lfs or set GIT_LFS_BINARY before building Gitty.",
|
||||
);
|
||||
}
|
||||
|
||||
const targetTriple = (
|
||||
process.env.GITTY_TARGET_TRIPLE
|
||||
?? process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
?? process.env.TARGET
|
||||
?? defaultTargetTriple()
|
||||
)?.trim();
|
||||
if (!targetTriple) throw new Error("Rust did not report a target triple.");
|
||||
|
||||
mkdirSync(binariesDirectory, { recursive: true });
|
||||
|
||||
const developmentTarget = join(binariesDirectory, `git-lfs${extension}`);
|
||||
const bundleTarget = join(
|
||||
binariesDirectory,
|
||||
`git-lfs-${targetTriple}${extension}`,
|
||||
);
|
||||
for (const target of [developmentTarget, bundleTarget]) {
|
||||
if (resolve(source) !== resolve(target)) copyFileSync(source, target);
|
||||
if (process.platform !== "win32") chmodSync(target, 0o755);
|
||||
}
|
||||
|
||||
console.log(`Prepared bundled git-lfs from ${source} for ${targetTriple}.`);
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
+141
-1
@@ -69,7 +69,9 @@
|
||||
fetchRemote,
|
||||
getCommitNote,
|
||||
getFileBlame,
|
||||
getGitLfsStatus,
|
||||
getStatus,
|
||||
installGitLfs,
|
||||
lastCommitMessage,
|
||||
listBranches,
|
||||
listRemotes,
|
||||
@@ -90,7 +92,9 @@
|
||||
lockWorktree,
|
||||
moveWorktree,
|
||||
pruneWorktrees,
|
||||
pruneGitLfsObjects,
|
||||
pull,
|
||||
pullGitLfsObjects,
|
||||
push,
|
||||
pushCommitNotes,
|
||||
pushTag,
|
||||
@@ -125,12 +129,14 @@
|
||||
startInteractiveRebase,
|
||||
setSyncBadge,
|
||||
stageFiles,
|
||||
trackGitLfsPattern,
|
||||
stashApply,
|
||||
stashDrop,
|
||||
stashPop,
|
||||
stashPush,
|
||||
undoLastCommit,
|
||||
unlockWorktree,
|
||||
untrackGitLfsPattern,
|
||||
unstageFiles,
|
||||
} from "./lib/git";
|
||||
|
||||
@@ -155,6 +161,7 @@
|
||||
GitCommitComparison,
|
||||
GitDiffFile,
|
||||
GitFileStatus,
|
||||
GitLfsStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
PullStrategy,
|
||||
@@ -376,6 +383,10 @@
|
||||
let worktrees: GitWorktree[] = [];
|
||||
let worktreesLoading = false;
|
||||
let worktreeError = "";
|
||||
let gitLfsDialogOpen = false;
|
||||
let gitLfsStatus: GitLfsStatus | null = null;
|
||||
let gitLfsLoading = false;
|
||||
let gitLfsError = "";
|
||||
let compareSelectOpen = false;
|
||||
let compareDialogOpen = false;
|
||||
let interactiveRebaseOpen = false;
|
||||
@@ -956,7 +967,7 @@
|
||||
}
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
|
||||
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
|
||||
const path = activeRepoPath;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
@@ -2955,6 +2966,114 @@
|
||||
worktreeError = "";
|
||||
}
|
||||
|
||||
async function openGitLfsDialog() {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
gitLfsDialogOpen = true;
|
||||
gitLfsStatus = null;
|
||||
gitLfsError = "";
|
||||
await refreshGitLfsStatus();
|
||||
const inspectedStatus = gitLfsStatus as GitLfsStatus | null;
|
||||
trackEvent("git_lfs_dialog_opened", {
|
||||
available: inspectedStatus?.available ? 1 : 0,
|
||||
repository_uses_lfs: inspectedStatus?.repository_uses_lfs ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshGitLfsStatus() {
|
||||
if (!activeRepoPath || gitLfsLoading) return;
|
||||
gitLfsLoading = true;
|
||||
gitLfsError = "";
|
||||
try {
|
||||
gitLfsStatus = await getGitLfsStatus(activeRepoPath);
|
||||
} catch (error) {
|
||||
gitLfsError = errorToMessage(error);
|
||||
} finally {
|
||||
gitLfsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGitLfsOperation(
|
||||
label: string,
|
||||
task: () => Promise<GitLfsStatus>,
|
||||
eventName: string,
|
||||
): Promise<boolean> {
|
||||
if (!activeRepoPath || isBusy) return false;
|
||||
const repository = activeRepoPath;
|
||||
operation = label;
|
||||
gitLfsError = "";
|
||||
try {
|
||||
gitLfsStatus = await task();
|
||||
applyStatus(await getStatus(repository));
|
||||
await refreshExplorerFiles(repository);
|
||||
trackEvent(eventName, {
|
||||
patterns: gitLfsStatus.patterns.length,
|
||||
files: gitLfsStatus.files.length,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
gitLfsError = errorToMessage(error);
|
||||
return false;
|
||||
} finally {
|
||||
operation = "";
|
||||
}
|
||||
}
|
||||
|
||||
function activateGitLfs() {
|
||||
return runGitLfsOperation(
|
||||
"Activating Git LFS",
|
||||
() => installGitLfs(activeRepoPath),
|
||||
"git_lfs_activated",
|
||||
);
|
||||
}
|
||||
|
||||
function addGitLfsPattern(pattern: string, lockable: boolean): Promise<boolean> {
|
||||
return runGitLfsOperation(
|
||||
`Tracking ${pattern} with Git LFS`,
|
||||
() => trackGitLfsPattern(activeRepoPath, pattern, lockable),
|
||||
"git_lfs_pattern_added",
|
||||
);
|
||||
}
|
||||
|
||||
async function removeGitLfsPattern(pattern: string) {
|
||||
await runGitLfsOperation(
|
||||
`Removing Git LFS pattern ${pattern}`,
|
||||
() => untrackGitLfsPattern(activeRepoPath, pattern),
|
||||
"git_lfs_pattern_removed",
|
||||
);
|
||||
}
|
||||
|
||||
async function pullGitLfsFiles() {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
let credential: StoredCredential | null = null;
|
||||
const key = await currentCredKey("pull");
|
||||
if (key) credential = await credLoad(key);
|
||||
await runGitLfsOperation(
|
||||
"Downloading Git LFS objects",
|
||||
() => pullGitLfsObjects(
|
||||
activeRepoPath,
|
||||
selectedRemote || undefined,
|
||||
credential?.username,
|
||||
credential?.password,
|
||||
),
|
||||
"git_lfs_objects_pulled",
|
||||
);
|
||||
}
|
||||
|
||||
async function pruneGitLfsCache() {
|
||||
await runGitLfsOperation(
|
||||
"Pruning Git LFS cache",
|
||||
() => pruneGitLfsObjects(activeRepoPath),
|
||||
"git_lfs_cache_pruned",
|
||||
);
|
||||
}
|
||||
|
||||
function closeGitLfsDialog() {
|
||||
if (isBusy) return;
|
||||
gitLfsDialogOpen = false;
|
||||
gitLfsStatus = null;
|
||||
gitLfsError = "";
|
||||
}
|
||||
|
||||
function openNewBranchDialog(commit: GitCommit) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
newBranchCommit = commit;
|
||||
@@ -4705,6 +4824,7 @@
|
||||
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
||||
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
|
||||
else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog();
|
||||
else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog();
|
||||
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
|
||||
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
|
||||
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||
@@ -4778,6 +4898,7 @@
|
||||
onFetchPrune={fetchPruneRepo}
|
||||
onForcePush={forcePushRepo}
|
||||
onSyncOptions={openSyncOptions}
|
||||
onOpenLfs={openGitLfsDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -5565,6 +5686,25 @@
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
{#if gitLfsDialogOpen}
|
||||
{#await import("./lib/components/GitLfsDialog.svelte") then module}
|
||||
<module.default
|
||||
status={gitLfsStatus}
|
||||
language={appLanguage}
|
||||
isLoading={gitLfsLoading}
|
||||
{isBusy}
|
||||
error={gitLfsError}
|
||||
onRefresh={refreshGitLfsStatus}
|
||||
onInstall={activateGitLfs}
|
||||
onTrack={addGitLfsPattern}
|
||||
onUntrack={removeGitLfsPattern}
|
||||
onPull={pullGitLfsFiles}
|
||||
onPrune={pruneGitLfsCache}
|
||||
onClose={closeGitLfsDialog}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Create a branch from a specific commit in the history -->
|
||||
{#if newBranchCommit}
|
||||
<NewBranchDialog
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Box,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
CloudDownload,
|
||||
@@ -41,6 +42,7 @@
|
||||
export let onFetchPrune: () => void = () => {};
|
||||
export let onForcePush: () => void = () => {};
|
||||
export let onSyncOptions: () => void = () => {};
|
||||
export let onOpenLfs: () => void = () => {};
|
||||
|
||||
let historyOpen = false;
|
||||
let syncOpen = false;
|
||||
@@ -142,6 +144,7 @@
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onOpenLfs(); }}><Box size={15} /><span><strong>Git LFS</strong><small>{isGerman ? "Große Dateien und LFS-Installation verwalten" : "Manage large files and LFS installation"}</small></span></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArchiveRestore,
|
||||
Box,
|
||||
Check,
|
||||
CircleDashed,
|
||||
Download,
|
||||
HardDriveDownload,
|
||||
Link2,
|
||||
LoaderCircle,
|
||||
PackageCheck,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
status: GitLfsStatus | null;
|
||||
language: AppLanguage;
|
||||
isLoading: boolean;
|
||||
isBusy: boolean;
|
||||
error?: string;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onInstall: () => boolean | void | Promise<boolean | void>;
|
||||
onTrack: (pattern: string, lockable: boolean) => boolean | Promise<boolean>;
|
||||
onUntrack: (pattern: string) => void | Promise<void>;
|
||||
onPull: () => void | Promise<void>;
|
||||
onPrune: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
status = null,
|
||||
language = "en",
|
||||
isLoading = false,
|
||||
isBusy = false,
|
||||
error = "",
|
||||
onRefresh = () => {},
|
||||
onInstall = () => {},
|
||||
onTrack = () => false,
|
||||
onUntrack = () => {},
|
||||
onPull = () => {},
|
||||
onPrune = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let pattern = $state("");
|
||||
let lockable = $state(false);
|
||||
let isGerman = $derived(language === "de");
|
||||
let setupReady = $derived(Boolean(status?.filters_configured && status?.hook_installed));
|
||||
let downloadedCount = $derived(status?.files.filter((file) => file.downloaded).length ?? 0);
|
||||
let totalSize = $derived(status?.files.reduce((sum, file) => sum + file.size, 0) ?? 0);
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!value) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
const amount = value / 1024 ** index;
|
||||
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function versionLabel(value: string | null | undefined): string {
|
||||
return value?.match(/git-lfs\/([^\s]+)/)?.[1] ?? value ?? "—";
|
||||
}
|
||||
|
||||
function canRemove(item: GitLfsPattern): boolean {
|
||||
return item.source === ".gitattributes";
|
||||
}
|
||||
|
||||
async function submitPattern(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = pattern.trim();
|
||||
if (!value || !setupReady) return;
|
||||
if (await onTrack(value, lockable)) {
|
||||
pattern = "";
|
||||
lockable = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPrune() {
|
||||
const confirmed = window.confirm(isGerman
|
||||
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
|
||||
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
|
||||
if (confirmed) await onPrune();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog lfs-dialog" role="dialog" aria-modal="true" aria-labelledby="lfs-dialog-title">
|
||||
<header class="dialog-header lfs-dialog-header">
|
||||
<div class="lfs-heading">
|
||||
<span class="lfs-mark" aria-hidden="true"><Box size={19} /></span>
|
||||
<div>
|
||||
<span class="eyebrow">Large file storage</span>
|
||||
<p class="dialog-title" id="lfs-dialog-title">Git LFS</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>
|
||||
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||
{isGerman ? "Prüfen" : "Check"}
|
||||
</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={isGerman ? "Schließen" : "Close"}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="lfs-main">
|
||||
{#if error}
|
||||
<div class="lfs-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading && !status}
|
||||
<div class="lfs-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{isGerman ? "Git LFS wird geprüft…" : "Inspecting Git LFS…"}</span></div>
|
||||
{:else if status}
|
||||
<div class="lfs-content">
|
||||
<section class="lfs-diagnostics" aria-label={isGerman ? "LFS-Diagnose" : "LFS diagnostics"}>
|
||||
<article class:ready={status.available} class:error={!status.available}>
|
||||
<span class="diagnostic-icon">{#if status.available}<PackageCheck size={18} />{:else}<AlertTriangle size={18} />{/if}</span>
|
||||
<div><small>01 · {isGerman ? "Erweiterung" : "Extension"}</small><strong>{status.available ? `Git LFS ${versionLabel(status.version)}` : (isGerman ? "Nicht gefunden" : "Not found")}</strong><span>{status.bundled ? (isGerman ? "Mit Gitty gebündelt" : "Bundled with Gitty") : (isGerman ? "Systeminstallation" : "System installation")}</span></div>
|
||||
</article>
|
||||
<span class:ready={status.available} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||
<article class:ready={setupReady} class:warning={status.available && !setupReady}>
|
||||
<span class="diagnostic-icon">{#if setupReady}<ShieldCheck size={18} />{:else}<CircleDashed size={18} />{/if}</span>
|
||||
<div><small>02 · Repository</small><strong>{setupReady ? (isGerman ? "Aktiv" : "Active") : (isGerman ? "Einrichtung nötig" : "Setup required")}</strong><span>{status.filters_configured ? "Filter ✓" : "Filter —"} · {status.hook_installed ? "Pre-push ✓" : "Pre-push —"}</span></div>
|
||||
</article>
|
||||
<span class:ready={setupReady} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||
<article class:ready={status.files.length > 0}>
|
||||
<span class="diagnostic-icon"><ArchiveRestore size={18} /></span>
|
||||
<div><small>03 · {isGerman ? "Objekte" : "Objects"}</small><strong>{status.files.length} {isGerman ? "Dateien" : "files"}</strong><span>{downloadedCount} {isGerman ? "lokal" : "local"} · {formatBytes(totalSize)}</span></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{#if !status.available}
|
||||
<section class="lfs-missing">
|
||||
<span class="lfs-missing-icon"><AlertTriangle size={22} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>{isGerman ? "Git LFS ist nicht ausführbar" : "Git LFS cannot be started"}</strong>
|
||||
<p>{isGerman ? "Gitty liefert Git LFS normalerweise mit. Installiere Gitty erneut oder installiere git-lfs systemweit und starte die App neu." : "Gitty normally includes Git LFS. Reinstall Gitty or install git-lfs system-wide, then restart the app."}</p>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
{#if !setupReady}
|
||||
<section class="lfs-setup-card">
|
||||
<div><strong>{isGerman ? "Für dieses Repository aktivieren" : "Activate for this repository"}</strong><p>{isGerman ? "Richtet Clean-/Smudge-Filter lokal ein und installiert den Pre-push-Hook. Globale Git-Einstellungen bleiben unverändert." : "Configures local clean/smudge filters and installs the pre-push hook. Global Git settings remain unchanged."}</p></div>
|
||||
<button class="btn-primary" type="button" onclick={onInstall} disabled={isBusy}><ShieldCheck size={15} aria-hidden="true" />{isGerman ? "LFS aktivieren" : "Activate LFS"}</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="lfs-grid">
|
||||
<section class="lfs-panel patterns-panel">
|
||||
<header><div><span class="eyebrow">Tracking rules</span><h3>{isGerman ? "Muster" : "Patterns"}</h3></div><span>{status.patterns.length}</span></header>
|
||||
<form class="lfs-track-form" onsubmit={submitPattern}>
|
||||
<label><span>{isGerman ? "Neues Muster" : "New pattern"}</span><input type="text" bind:value={pattern} disabled={isBusy || !setupReady} autocomplete="off" spellcheck="false" placeholder="*.psd, Assets/**, video.mp4" /></label>
|
||||
<label class="lfs-lockable"><input type="checkbox" bind:checked={lockable} disabled={isBusy || !setupReady} /><span><strong>Lockable</strong><small>{isGerman ? "Schreibgeschützt, solange nicht gesperrt" : "Read-only until locked"}</small></span></label>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || !setupReady || !pattern.trim()}><Plus size={15} aria-hidden="true" />{isGerman ? "Hinzufügen" : "Add"}</button>
|
||||
</form>
|
||||
|
||||
<div class="lfs-pattern-list">
|
||||
{#if status.patterns.length === 0}
|
||||
<div class="lfs-empty"><Box size={20} aria-hidden="true" /><span>{isGerman ? "Noch keine Dateien werden über LFS verwaltet." : "No files are tracked through LFS yet."}</span></div>
|
||||
{:else}
|
||||
{#each status.patterns as item (`${item.source}:${item.pattern}`)}
|
||||
<article>
|
||||
<div><code>{item.pattern}</code><span>{item.source}{item.lockable ? " · lockable" : ""}</span></div>
|
||||
<button class="btn-sm danger" type="button" onclick={() => onUntrack(item.pattern)} disabled={isBusy || !canRemove(item)} title={canRemove(item) ? (isGerman ? "Muster entfernen" : "Remove pattern") : (isGerman ? "Nur Muster aus der Wurzel-.gitattributes können hier entfernt werden" : "Only patterns from the root .gitattributes can be removed here") }><Trash2 size={14} aria-hidden="true" /></button>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lfs-panel objects-panel">
|
||||
<header><div><span class="eyebrow">Current checkout</span><h3>{isGerman ? "LFS-Dateien" : "LFS files"}</h3></div><span>{status.files.length}</span></header>
|
||||
<div class="lfs-object-list">
|
||||
{#if status.files.length === 0}
|
||||
<div class="lfs-empty"><HardDriveDownload size={20} aria-hidden="true" /><span>{isGerman ? "Im aktuellen Stand sind keine LFS-Objekte vorhanden." : "The current checkout has no LFS objects."}</span></div>
|
||||
{:else}
|
||||
{#each status.files as file (`${file.oid}:${file.name}`)}
|
||||
<article>
|
||||
<span class:downloaded={file.downloaded} class="object-state" title={file.downloaded ? (isGerman ? "Objekt lokal vorhanden" : "Object available locally") : (isGerman ? "Nur LFS-Zeiger vorhanden" : "LFS pointer only")}>{#if file.downloaded}<Check size={12} />{:else}<Download size={12} />{/if}</span>
|
||||
<div title={file.name}><strong>{file.name}</strong><span>{formatBytes(file.size)} · <code>{file.oid.slice(0, 10)}</code></span></div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="lfs-footer">
|
||||
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
|
||||
<div>
|
||||
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
|
||||
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.lfs-dialog { --lfs-success: #4eca76; --lfs-warning: #f0b648; --lfs-danger: #e86060; width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(660px, calc(100vh - 32px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border-radius: 12px; }
|
||||
.lfs-dialog-header, .lfs-heading, .dialog-header-actions, .lfs-footer, .lfs-footer > div { display: flex; align-items: center; }
|
||||
.lfs-dialog-header { min-height: 58px; justify-content: space-between; padding: 10px 13px; }
|
||||
.lfs-heading { gap: 9px; }
|
||||
.lfs-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
.dialog-header-actions { gap: 7px; }
|
||||
.lfs-main { min-height: 0; overflow: hidden; }
|
||||
.lfs-error { display: flex; align-items: center; gap: 8px; margin: 10px 13px 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); border-radius: 7px; color: var(--lfs-danger); background: color-mix(in srgb, var(--lfs-danger) 8%, transparent); font-size: 11px; }
|
||||
.lfs-loading { display: grid; min-height: 280px; place-items: center; align-content: center; gap: 10px; color: var(--color-ink-dim); font-size: 12px; }
|
||||
.lfs-content { min-height: 0; max-height: 548px; padding: 12px 13px 13px; overflow: auto; }
|
||||
.lfs-diagnostics { display: grid; grid-template-columns: minmax(0, 1fr) 22px minmax(0, 1fr) 22px minmax(0, 1fr); align-items: center; margin-bottom: 11px; }
|
||||
.lfs-diagnostics article { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-height: 64px; padding: 8px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.lfs-diagnostics article.ready { border-color: color-mix(in srgb, var(--lfs-success) 34%, var(--color-border)); }
|
||||
.lfs-diagnostics article.warning { border-color: color-mix(in srgb, var(--lfs-warning) 38%, var(--color-border)); }
|
||||
.lfs-diagnostics article.error { border-color: color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); }
|
||||
.diagnostic-icon { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 8px; color: var(--color-ink-dim); background: var(--color-surface-hover); }
|
||||
article.ready .diagnostic-icon { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||
article.warning .diagnostic-icon { color: var(--lfs-warning); }
|
||||
article.error .diagnostic-icon { color: var(--lfs-danger); }
|
||||
.lfs-diagnostics article div { display: grid; min-width: 0; gap: 2px; }
|
||||
.lfs-diagnostics small { color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.lfs-diagnostics strong { overflow: hidden; color: var(--color-ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lfs-diagnostics article div > span { color: var(--color-ink-dim); font-size: 10px; }
|
||||
.diagnostic-link { display: grid; place-items: center; color: var(--color-border); }
|
||||
.diagnostic-link.ready { color: color-mix(in srgb, var(--lfs-success) 60%, var(--color-border)); }
|
||||
.lfs-missing, .lfs-setup-card { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.lfs-missing-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--lfs-danger); }
|
||||
.lfs-missing strong, .lfs-setup-card strong { color: var(--color-ink); font-size: 12px; }
|
||||
.lfs-missing p, .lfs-setup-card p { margin: 4px 0 0; color: var(--color-ink-dim); font-size: 10.5px; line-height: 1.5; }
|
||||
.lfs-setup-card { justify-content: space-between; margin-bottom: 11px; border-color: color-mix(in srgb, var(--lfs-warning) 32%, var(--color-border)); }
|
||||
.lfs-setup-card > div { max-width: 650px; }
|
||||
.lfs-setup-card button { flex: 0 0 auto; }
|
||||
.lfs-grid { display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 10px; height: clamp(230px, 30vh, 285px); min-height: 0; }
|
||||
.lfs-panel { display: grid; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); border: 1px solid var(--color-border-subtle); border-radius: 10px; overflow: hidden; background: var(--color-surface-raised); }
|
||||
.objects-panel { grid-template-rows: auto minmax(0, 1fr); }
|
||||
.lfs-panel > header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 74%, transparent); }
|
||||
.lfs-panel h3 { margin: 2px 0 0; color: var(--color-ink); font-size: 13px; }
|
||||
.lfs-panel > header > span { display: grid; min-width: 25px; height: 22px; place-items: center; border-radius: 11px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); font-size: 10px; font-weight: 800; }
|
||||
.lfs-track-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px 9px; padding: 9px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.lfs-track-form > label:first-child { display: grid; gap: 5px; grid-column: 1 / -1; }
|
||||
.lfs-track-form label > span:first-child { color: var(--color-ink-dim); font-size: 9.5px; font-weight: 700; }
|
||||
.lfs-track-form input[type="text"] { width: 100%; }
|
||||
.lfs-lockable { display: inline-flex; width: fit-content; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
|
||||
.lfs-lockable input[type="checkbox"] { width: 14px; min-width: 14px; height: 14px; margin: 0; padding: 0; border-radius: 3px; accent-color: var(--color-accent); cursor: pointer; box-shadow: none; }
|
||||
.lfs-lockable > span { display: flex; align-items: baseline; gap: 4px; white-space: nowrap; }
|
||||
.lfs-lockable strong { color: var(--color-ink); font-size: 10px; }
|
||||
.lfs-lockable small { color: var(--color-ink-faint); font-size: 8.5px; }
|
||||
.lfs-pattern-list, .lfs-object-list { min-height: 0; overflow: auto; }
|
||||
.lfs-pattern-list article, .lfs-object-list article { display: flex; align-items: center; gap: 9px; min-height: 49px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.lfs-pattern-list article:last-child, .lfs-object-list article:last-child { border-bottom: 0; }
|
||||
.lfs-pattern-list article > div, .lfs-object-list article > div { display: grid; min-width: 0; flex: 1; gap: 3px; }
|
||||
.lfs-pattern-list code, .lfs-object-list strong { overflow: hidden; color: var(--color-ink); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lfs-pattern-list article span, .lfs-object-list article span { color: var(--color-ink-faint); font-size: 9px; }
|
||||
.object-state { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--lfs-warning); background: color-mix(in srgb, var(--lfs-warning) 10%, transparent); }
|
||||
.object-state.downloaded { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||
.lfs-empty { display: grid; width: 100%; height: 100%; min-height: 80px; place-items: center; align-content: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
|
||||
.lfs-empty span { max-width: 260px; font-size: 10px; line-height: 1.45; }
|
||||
.lfs-footer { min-height: 54px; justify-content: space-between; gap: 14px; padding: 9px 13px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.lfs-footer > div { gap: 7px; }
|
||||
.lfs-footer > div:first-child { min-width: 0; color: var(--color-ink-dim); font-size: 9.5px; }
|
||||
.lfs-footer > div:last-child { flex: 0 0 auto; gap: 8px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dialog-backdrop { padding: 10px; }
|
||||
.lfs-dialog { width: min(620px, 100%); height: min(760px, 100%); max-height: none; }
|
||||
.lfs-content { height: 100%; max-height: none; }
|
||||
.lfs-diagnostics { grid-template-columns: 1fr; gap: 7px; }
|
||||
.diagnostic-link { display: none; }
|
||||
.lfs-grid { grid-template-columns: 1fr; height: auto; }
|
||||
.lfs-panel { min-height: 210px; }
|
||||
.lfs-footer { align-items: flex-start; flex-direction: column; }
|
||||
.lfs-footer > div:last-child { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.lfs-dialog-header { min-height: 54px; }
|
||||
.lfs-mark { width: 31px; height: 31px; }
|
||||
.lfs-track-form { grid-template-columns: 1fr; }
|
||||
.lfs-track-form > label:first-child { grid-column: 1; }
|
||||
.lfs-track-form button { justify-self: end; }
|
||||
.lfs-lockable > span { white-space: normal; }
|
||||
.lfs-footer > div:first-child { display: none; }
|
||||
.lfs-footer > div:last-child { justify-content: stretch; }
|
||||
.lfs-footer button { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Box,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
@@ -468,6 +469,126 @@
|
||||
},
|
||||
];
|
||||
|
||||
deCategories.splice(deCategories.findIndex((category) => category.id === "remote") + 1, 0, {
|
||||
id: "lfs",
|
||||
label: "Git LFS",
|
||||
description: "Große Binärdateien tracken, Objekte synchronisieren und bestehende Repositories sicher umstellen.",
|
||||
sections: [
|
||||
{
|
||||
id: "lfs-overview",
|
||||
title: "Was Git LFS macht",
|
||||
summary: "Git LFS ersetzt große Dateien im Git-Verlauf durch kleine Zeigerdateien. Die eigentlichen Inhalte liegen im LFS-Speicher des Remotes und werden beim Checkout oder Pull passend geladen.",
|
||||
steps: [
|
||||
"Nutze LFS vor allem für große Binärdateien wie PSD-, Video-, Audio-, Modell- oder Archivdateien, die Git nicht sinnvoll als Text-Diff verwalten kann.",
|
||||
"Gitty liefert die Git-LFS-Erweiterung in Desktop-Installern mit und zeigt Version, Filter sowie Pre-push-Hook im LFS-Dialog an.",
|
||||
"Die LFS-Regeln stehen in .gitattributes und gehören deshalb wie normaler Quellcode in das Repository.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-setup",
|
||||
title: "Git LFS in Gitty einrichten",
|
||||
summary: "Die Einrichtung gilt für das aktuell geöffnete Repository und verändert keine globalen Git-Einstellungen.",
|
||||
steps: [
|
||||
"Öffne im Repository das Menü Synchronisieren und wähle Git LFS.",
|
||||
"Klicke auf LFS aktivieren, damit Gitty die lokalen Filter und den Pre-push-Hook einrichtet.",
|
||||
"Füge ein Muster wie *.psd, Assets/** oder video.mp4 hinzu. Lockable markiert Dateien, die über einen kompatiblen LFS-Server gesperrt werden können.",
|
||||
"Stage und committe anschließend .gitattributes zusammen mit den gewünschten Dateien.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs install --local", description: "LFS nur im aktuellen Repository aktivieren" },
|
||||
{ command: "git lfs track \"*.psd\"", description: "Ein Dateimuster über Git LFS verwalten" },
|
||||
{ command: "git add .gitattributes", description: "Die erzeugten Tracking-Regeln stagen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.",
|
||||
steps: [
|
||||
"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.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs status", description: "LFS-Zustand und ausstehende Änderungen prüfen" },
|
||||
{ command: "git lfs pull", description: "Benötigte LFS-Objekte manuell laden" },
|
||||
{ command: "git lfs prune", description: "Nicht mehr benötigte lokale LFS-Objekte bereinigen" },
|
||||
{ command: "git lfs lock <datei>", description: "Eine als lockable markierte Datei auf einem kompatiblen Remote sperren" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-migrate",
|
||||
title: "Bestehende Dateien umstellen",
|
||||
summary: "Ein neu hinzugefügtes Tracking-Muster schreibt vorhandene Commits nicht rückwirkend um. Aktuelle Dateien lassen sich neu normalisieren; eine vollständige Migration verändert dagegen die Historie.",
|
||||
commands: [
|
||||
{ command: "git add --renormalize .", description: "Aktuelle Dateien erneut durch die neuen LFS-Regeln führen" },
|
||||
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Passende Dateien in der gesamten Historie nach LFS migrieren" },
|
||||
],
|
||||
note: "Vorsicht: git lfs migrate import schreibt Commit-Hashes um. Stimme die Migration mit allen Beteiligten ab, erstelle vorher ein Backup und rechne bei bereits veröffentlichten Branches mit einem koordinierten Force-Push.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
enCategories.splice(enCategories.findIndex((category) => category.id === "remote") + 1, 0, {
|
||||
id: "lfs",
|
||||
label: "Git LFS",
|
||||
description: "Track large binary files, synchronize objects, and migrate existing repositories safely.",
|
||||
sections: [
|
||||
{
|
||||
id: "lfs-overview",
|
||||
title: "What Git LFS does",
|
||||
summary: "Git LFS replaces large files in Git history with small pointer files. The actual content is stored in the remote's LFS storage and downloaded for the relevant checkout or pull.",
|
||||
steps: [
|
||||
"Use LFS mainly for large binary files such as PSDs, videos, audio, models, or archives that Git cannot usefully manage as text diffs.",
|
||||
"Gitty bundles the Git LFS extension in desktop installers and displays its version, filters, and pre-push hook in the LFS dialog.",
|
||||
"LFS rules live in .gitattributes, so commit them to the repository like regular source code.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-setup",
|
||||
title: "Set up Git LFS in Gitty",
|
||||
summary: "Setup applies to the currently open repository and does not change global Git settings.",
|
||||
steps: [
|
||||
"Open the Sync menu in the repository and select Git LFS.",
|
||||
"Select Activate LFS so Gitty configures the local filters and pre-push hook.",
|
||||
"Add a pattern such as *.psd, Assets/**, or video.mp4. Lockable marks files that can be locked through a compatible LFS server.",
|
||||
"Stage and commit .gitattributes together with the files you want to track.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs install --local", description: "Activate LFS only in the current repository" },
|
||||
{ command: "git lfs track \"*.psd\"", description: "Manage a file pattern through Git LFS" },
|
||||
{ command: "git add .gitattributes", description: "Stage the generated tracking rules" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.",
|
||||
steps: [
|
||||
"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.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs status", description: "Inspect LFS state and pending changes" },
|
||||
{ command: "git lfs pull", description: "Download required LFS objects manually" },
|
||||
{ command: "git lfs prune", description: "Remove unused local LFS objects" },
|
||||
{ command: "git lfs lock <file>", description: "Lock a lockable file on a compatible remote" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-migrate",
|
||||
title: "Migrate existing files",
|
||||
summary: "Adding a tracking pattern does not rewrite existing commits. Current files can be renormalized, while a complete migration changes repository history.",
|
||||
commands: [
|
||||
{ command: "git add --renormalize .", description: "Run current files through the new LFS rules again" },
|
||||
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Move matching files to LFS throughout repository history" },
|
||||
],
|
||||
note: "Caution: git lfs migrate import rewrites commit hashes. Coordinate the migration with every contributor, create a backup first, and expect a coordinated force push for published branches.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Extended handbook chapters. Keeping these additions next to the shared data makes
|
||||
// it straightforward to compare the German and English coverage section by section.
|
||||
deCategories.find((category) => category.id === "start")?.sections.push(
|
||||
@@ -1703,6 +1824,7 @@
|
||||
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
||||
{:else if category.id === "remote"}<Cloud size={17} aria-hidden="true" />
|
||||
{:else if category.id === "lfs"}<Box size={17} aria-hidden="true" />
|
||||
{:else if category.id === "troubleshooting"}<Wrench size={17} aria-hidden="true" />
|
||||
{:else if category.id === "workflows"}<ListChecks size={17} aria-hidden="true" />
|
||||
{:else if category.id === "reference"}<Library size={17} aria-hidden="true" />
|
||||
@@ -1715,7 +1837,7 @@
|
||||
|
||||
<div class="help-nav-tip">
|
||||
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
|
||||
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>stash</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
|
||||
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>lfs</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitLfsStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
MergeStrategy,
|
||||
@@ -90,6 +91,35 @@ export function getStatus(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("get_status", { path });
|
||||
}
|
||||
|
||||
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_status", { path });
|
||||
}
|
||||
|
||||
export function installGitLfs(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_install", { path });
|
||||
}
|
||||
|
||||
export function trackGitLfsPattern(path: string, pattern: string, lockable = false): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_track", { path, pattern, lockable });
|
||||
}
|
||||
|
||||
export function untrackGitLfsPattern(path: string, pattern: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_untrack", { path, pattern });
|
||||
}
|
||||
|
||||
export function pullGitLfsObjects(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_pull", {
|
||||
path,
|
||||
remote: remote || null,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function pruneGitLfsObjects(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_prune", { path });
|
||||
}
|
||||
|
||||
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
|
||||
// other platforms, since Windows has no native numeric badge to fall back to.
|
||||
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
|
||||
|
||||
@@ -193,6 +193,34 @@ export interface GitRepositoryFile {
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export interface GitLfsPattern {
|
||||
pattern: string;
|
||||
source: string;
|
||||
lockable: boolean;
|
||||
tracked: boolean;
|
||||
}
|
||||
|
||||
export interface GitLfsFile {
|
||||
name: string;
|
||||
size: number;
|
||||
checkout: boolean;
|
||||
downloaded: boolean;
|
||||
oid_type: string;
|
||||
oid: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface GitLfsStatus {
|
||||
available: boolean;
|
||||
bundled: boolean;
|
||||
version: string | null;
|
||||
filters_configured: boolean;
|
||||
hook_installed: boolean;
|
||||
repository_uses_lfs: boolean;
|
||||
patterns: GitLfsPattern[];
|
||||
files: GitLfsFile[];
|
||||
}
|
||||
|
||||
export interface RepositoryBundle {
|
||||
status: GitStatus;
|
||||
branches: GitBranch[];
|
||||
|
||||
Reference in New Issue
Block a user