Compare commits

...
4 Commits
Author SHA1 Message Date
Christoph Brandau 7d3288a7ef chore(release): bump to 2026.8.6 and update changelog
publish / Build and publish Windows installer (release) Successful in 21m54s
publish / Build and publish Ubuntu AppImage (release) Successful in 20m10s
publish / Build and publish AUR packages (release) Successful in 48m1s
Bump version to 2026.8.6 across packaging, app config, and docs.
The change aligns binaries, config files, and changelog with the new release.
A new 2026.8.6 entry is added to the changelog and the UI surfaces release notes.

- Aligns version across packaging, app config, and UI
- Adds 2026.8.6 changelog entry and release notes in UI
- Prepares release by updating docs and build metadata
2026-08-18 21:43:32 +02:00
Christoph Brandau 6d806e87ea feat(git-lfs): improve activation and add HTTP/1.1 retry
The change adds a safer LFS activation flow that ensures a
root .gitattributes file is not hidden by ignore rules and
activates local LFS filters. It also merges patterns from the
repository attributes with those reported by Git LFS to avoid
duplicates and inaccuracies.

- Adds a retry path for large LFS uploads by forcing HTTP/1.1
during pushes when an HTTP 413 error is returned.
2026-08-18 21:36:13 +02:00
Christoph Brandau 7408371430 feat(startup): enable startup clone requests from CLI and IPC
Adds startup clone support with a new clone request type and parsing.
It wires a CLI and IPC pathway to forward a clone to a running app.
UI and docs were updated to reflect startup clone behavior.

- Introduce StartupCloneRequest and argument parsing.
- Wire IPC to pass clone requests and clone on startup.
- UI updated to queue clone requests and trigger clone.
2026-08-18 17:41:15 +02:00
Christoph Brandau e4697c74b6 feat(git): add ignore and untrack paths commands
The changes introduce server-side commands to manage gitignore
 rules and to untrack paths without deleting local files.
 A new GitIgnoreKind enum and helper functions normalize targets
 and build proper ignore patterns, and UI code was wired to use
 these commands.

- add_to_gitignore command and related helpers
- untrack_paths command to remove paths from the index
- UI wiring to expose ignore and untrack actions in explorer
2026-08-18 13:09:33 +02:00
18 changed files with 1383 additions and 128 deletions
+34 -3
View File
@@ -4,6 +4,27 @@ All notable user-facing changes to Gitty are documented in this file.
The project uses calendar-style versions in the form `YYYY.M.PATCH`. The project uses calendar-style versions in the form `YYYY.M.PATCH`.
## [2026.8.6] - 2026-08-18
### Changed
- Activating LFS or adding a tracking pattern now ensures that a root
`.gitattributes` file is not hidden by Git ignore rules. When necessary,
Gitty adds the scoped `!/.gitattributes` exception to `.gitignore`.
- The standard Tauri development launcher now removes an injected non-routing
`127.0.0.1:9` proxy and blocking SSH placeholder from the debug child
process, while preserving real user and company proxy settings.
### Fixed
- Root `.gitattributes` LFS patterns remain visible while the file is
untracked or ignored, instead of disappearing from the LFS dialog.
- Git LFS pushes rejected with HTTP 413 are retried once with a command-scoped
HTTP/1.1 override, which works around Azure DevOps' large HTTP/2 upload
behavior without changing repository or global Git settings.
- LFS and other generic push failures no longer trigger the unrelated
Pull/Push retry flow intended only for non-fast-forward rejections.
## [2026.8.5] - 2026-08-17 ## [2026.8.5] - 2026-08-17
### Added ### Added
@@ -18,14 +39,23 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
existing file actions. existing file actions.
- Files and folders in the Changes panel now have context-menu actions for - Files and folders in the Changes panel now have context-menu actions for
staging or unstaging their scope and for creating a stash containing only staging or unstaging their scope and for creating a stash containing only
the selected file or folder. the selected file or folder. New and untracked items can also be added to
the repository `.gitignore` from Changes or the File Explorer as an exact
file, a complete folder, or an extension-wide pattern. Folder rules are only
offered for folder selections. Tracked files and folders can be removed from
the Git index without deleting their working-tree contents.
- Gitty can open a repository directly at startup through the `--repo PATH` or - Gitty can open a repository directly at startup through the `--repo PATH` or
`--repo=PATH` command-line argument. `--repo=PATH` command-line argument.
- The executable can clone and immediately open a repository with
`clone REMOTE TARGET`, `--clone REMOTE TARGET`, or `--clone=REMOTE TARGET`.
Relative targets use the caller's working directory, and requests are also
forwarded to an already-running Gitty instance.
### Changed ### Changed
- A successful Git pull now detects repositories that use LFS and downloads - Successful clones and pulls now detect repositories that use LFS and
the required LFS objects automatically with the same remote and credentials. download the required LFS objects automatically with the same remote and
credentials. Fresh clones also activate LFS locally before they are opened.
- Unstaged and staged changes use an equal-width side-by-side layout with - Unstaged and staged changes use an equal-width side-by-side layout with
independent scrolling, directional stage/unstage actions, and a responsive independent scrolling, directional stage/unstage actions, and a responsive
vertical fallback. The List/Tree switch is centered above both areas. vertical fallback. The List/Tree switch is centered above both areas.
@@ -213,6 +243,7 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22 [2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21 [2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20 [2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
[2026.8.6]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.6
[2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5 [2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5
[2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4 [2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3 [2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com> # Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
pkgname=gitty-desktop-bin pkgname=gitty-desktop-bin
pkgver=2026.8.5 pkgver=2026.8.6
pkgrel=1 pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)" pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)"
arch=('x86_64') arch=('x86_64')
+40 -5
View File
@@ -82,17 +82,52 @@ files.
--- ---
## Command line
Open an existing repository when Gitty starts:
```text
gitty.exe --repo "D:\Projects\ExistingRepo"
```
Clone a remote into an exact local target folder and open it immediately:
```text
gitty.exe --clone "https://example.com/team/project.git" "D:\Projects\Project"
gitty clone "git@example.com:team/project.git" "D:\Projects\Project"
```
`--clone=<REMOTE>` is also accepted. Relative target paths are resolved from
the current working directory. Clone and repository requests are forwarded to
the running window when Gitty is already open.
---
## Development
Start the complete desktop application in development mode with:
```bash
npm run tauri:dev
```
The development launcher preserves normal proxy settings. If a sandboxed
development terminal injects the non-routing `127.0.0.1:9` proxy or its
blocking SSH placeholder, those values are removed only for the Tauri child
process so Git remotes and Git LFS remain testable in the debug application.
---
## Git LFS ## Git LFS
Gitty bundles the `git-lfs` executable in its desktop installers and checks it 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 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 a dependency so Git hooks and command-line workflows outside Gitty use the same
extension. The repository toolbar exposes LFS setup, tracked patterns, object extension. The repository toolbar exposes LFS setup, tracked patterns, object
downloads, and safe cache pruning. Normal clone, pull, checkout, and push downloads, and safe cache pruning. After every successful clone or pull, Gitty
operations continue to use Git's standard LFS filters and pre-push hook. After detects LFS usage and automatically downloads the required LFS objects with the
every successful pull, Gitty detects LFS usage and automatically downloads the same remote and credentials, so no second pull is needed. Fresh clones also get
required LFS objects with the same remote and credentials, so no second pull is repository-local LFS filters and the pre-push hook before they are opened.
needed.
--- ---
+2
View File
@@ -137,6 +137,8 @@ The command list below includes the repository-management and synchronization AP
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>` - `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `stage_files(path: string, files: string[]): Promise<GitStatus>` - `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>` - `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `add_to_gitignore(path: string, target: string, kind: "file" | "extension" | "folder"): Promise<GitStatus>`; appends a repository-root `.gitignore` rule and unstages newly-added matching files.
- `untrack_paths(path: string, targets: string[]): Promise<GitStatus>`; removes files or folders from the Git index while preserving their working-tree contents.
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>` - `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed. - `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
- `commit(path: string, message: string): Promise<GitStatus>` - `commit(path: string, message: string): Promise<GitStatus>`
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.8.5", "version": "2026.8.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.8.5", "version": "2026.8.6",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.8.5", "version": "2026.8.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,7 +10,7 @@
"icons": "node scripts/generate-app-icons.mjs", "icons": "node scripts/generate-app-icons.mjs",
"preview": "vite preview --host 127.0.0.1", "preview": "vite preview --host 127.0.0.1",
"tauri": "tauri", "tauri": "tauri",
"tauri:dev": "tauri dev", "tauri:dev": "node scripts/tauri-dev.mjs",
"tauri:build": "tauri build", "tauri:build": "tauri build",
"check": "svelte-check --tsconfig ./tsconfig.json" "check": "svelte-check --tsconfig ./tsconfig.json"
}, },
+61
View File
@@ -0,0 +1,61 @@
import { spawn } from "node:child_process";
const debugEnvironment = { ...process.env };
const clearedVariables = [];
const blockedProxy = /^https?:\/\/127\.0\.0\.1:9\/?$/i;
const proxyVariables = new Set([
"all_proxy",
"http_proxy",
"https_proxy",
"git_http_proxy",
"git_https_proxy",
]);
for (const [name, value] of Object.entries(debugEnvironment)) {
if (proxyVariables.has(name.toLowerCase()) && blockedProxy.test(value ?? "")) {
delete debugEnvironment[name];
clearedVariables.push(name);
}
}
for (const [name, value] of Object.entries(debugEnvironment)) {
if (
name.toLowerCase() === "git_ssh_command"
&& /^cmd(?:\.exe)?\s+\/c\s+exit\s+1$/i.test((value ?? "").trim())
) {
delete debugEnvironment[name];
clearedVariables.push(name);
}
}
if (process.argv.includes("--check-environment")) {
process.stdout.write(
clearedVariables.length > 0
? `Debug environment ready; cleared: ${clearedVariables.sort().join(", ")}\n`
: "Debug environment ready; no blocked proxy variables found.\n",
);
process.exit(0);
}
if (clearedVariables.length > 0) {
process.stdout.write(
`Starting Gitty without the blocked debug proxy (${clearedVariables.sort().join(", ")}).\n`,
);
}
const isWindows = process.platform === "win32";
const child = spawn(isWindows ? "tauri.cmd" : "tauri", ["dev"], {
cwd: process.cwd(),
env: debugEnvironment,
stdio: "inherit",
shell: isWindows,
});
child.on("error", (error) => {
process.stderr.write(`Could not start Tauri development mode: ${error.message}\n`);
process.exitCode = 1;
});
child.on("exit", (code) => {
process.exitCode = code ?? 1;
});
+716 -23
View File
@@ -4,6 +4,7 @@ use std::{
env, env,
ffi::{OsStr, OsString}, ffi::{OsStr, OsString},
fs, fs,
io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Command, Output, Stdio}, process::{Command, Output, Stdio},
sync::{ sync::{
@@ -32,6 +33,14 @@ pub enum FileStatusKind {
Unknown, Unknown,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GitIgnoreKind {
File,
Extension,
Folder,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitFileStatus { pub struct GitFileStatus {
pub path: String, pub path: String,
@@ -542,6 +551,7 @@ pub struct RepositoryBundle {
pub stashes: Vec<GitStash>, pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>, pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>, pub files: Vec<GitRepositoryFile>,
pub warning: Option<String>,
} }
async fn run_git_task<T, F>(context: &'static str, task: F) -> Result<T, String> async fn run_git_task<T, F>(context: &'static str, task: F) -> Result<T, String>
@@ -591,6 +601,7 @@ fn repository_bundle_for_repo(
stashes, stashes,
commits, commits,
files, files,
warning: None,
}) })
} }
@@ -656,8 +667,7 @@ pub async fn git_lfs_status(path: String) -> Result<GitLfsStatus, String> {
pub async fn git_lfs_install(path: String) -> Result<GitLfsStatus, String> { pub async fn git_lfs_install(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not activate Git LFS", move || { run_git_task("Could not activate Git LFS", move || {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?; activate_git_lfs_for_repo(&repo)?;
run_git(&repo, ["lfs", "install", "--local"])?;
git_lfs_status_for_repo(&repo) git_lfs_status_for_repo(&repo)
}) })
.await .await
@@ -673,6 +683,7 @@ pub async fn git_lfs_track(
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?; ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?; let pattern = validate_lfs_pattern(&pattern)?;
ensure_root_gitattributes_not_ignored(&repo)?;
let mut args = vec![OsString::from("lfs"), OsString::from("track")]; let mut args = vec![OsString::from("lfs"), OsString::from("track")];
if lockable.unwrap_or(false) { if lockable.unwrap_or(false) {
args.push(OsString::from("--lockable")); args.push(OsString::from("--lockable"));
@@ -2029,6 +2040,30 @@ pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus
.map_err(|err| format!("Could not unstage files: {err}"))? .map_err(|err| format!("Could not unstage files: {err}"))?
} }
#[tauri::command]
pub async fn add_to_gitignore(
path: String,
target: String,
kind: GitIgnoreKind,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
add_to_gitignore_for_repo(&repo, &target, kind)
})
.await
.map_err(|err| format!("Could not update .gitignore: {err}"))?
}
#[tauri::command]
pub async fn untrack_paths(path: String, targets: Vec<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
untrack_paths_for_repo(&repo, &targets)
})
.await
.map_err(|err| format!("Could not stop tracking paths: {err}"))?
}
#[tauri::command] #[tauri::command]
pub async fn restore_files( pub async fn restore_files(
path: String, path: String,
@@ -2652,15 +2687,13 @@ pub async fn pull(
}; };
if output.status.success() { if output.status.success() {
if repository_uses_lfs(&repo) { sync_git_lfs_objects_if_needed(
ensure_git_lfs_available()?;
pull_git_lfs_objects(
&repo, &repo,
remote.as_deref(), remote.as_deref(),
username.as_deref(), username.as_deref(),
password.as_deref(), password.as_deref(),
false,
)?; )?;
}
return status_for_repo(&repo); return status_for_repo(&repo);
} }
@@ -2734,24 +2767,89 @@ pub async fn push(
force_with_lease: Option<bool>, force_with_lease: Option<bool>,
remote: Option<String>, remote: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { let result = tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?; let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?;
if force_with_lease.unwrap_or(false) { if force_with_lease.unwrap_or(false) {
push_args.insert(1, OsString::from("--force-with-lease")); push_args.insert(1, OsString::from("--force-with-lease"));
} }
match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { if let Err(error) = run_push_command(
run_git_authenticated(&repo, push_args, u, p)?; &repo,
} push_args.clone(),
_ => { username.as_deref(),
run_git(&repo, push_args)?; password.as_deref(),
) {
if !is_lfs_http_413_error(&error) {
return Err(error);
} }
// Azure DevOps can reject LFS objects above roughly 128 MB when
// HTTP/2 sends them with one large Content-Length. Git LFS can
// transfer the same object over HTTP/1.1 using chunked encoding.
// Keep this override scoped to the retry instead of changing the
// user's repository or global Git configuration.
log::warn!(
target: "gitty::remote",
"Git LFS upload returned HTTP 413; retrying push with HTTP/1.1"
);
run_push_command(
&repo,
push_args_with_http_1_1(push_args),
username.as_deref(),
password.as_deref(),
)
.map_err(|retry_error| {
format!(
"Git LFS upload was rejected with HTTP 413 even after retrying with HTTP/1.1: {retry_error}"
)
})?;
} }
status_for_repo(&repo) status_for_repo(&repo)
}) })
.await .await
.map_err(|err| format!("Could not push: {err}"))? .map_err(|err| format!("Could not push: {err}"))?;
match &result {
Ok(_) => log::info!(target: "gitty::remote", "push completed successfully"),
Err(error) => log::error!(target: "gitty::remote", "push failed: {error}"),
}
result
}
fn run_push_command(
repo: &Path,
args: Vec<OsString>,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, args, user, pass)?;
}
_ => {
run_git(repo, args)?;
}
}
Ok(())
}
fn is_lfs_http_413_error(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("lfs:")
&& (error.contains("http 413")
|| error.contains("413 content too large")
|| error.contains("413 payload too large")
|| error.contains("413 request entity too large"))
}
fn push_args_with_http_1_1(push_args: Vec<OsString>) -> Vec<OsString> {
let mut retry_args = vec![
OsString::from("-c"),
OsString::from("http.version=HTTP/1.1"),
];
retry_args.extend(push_args);
retry_args
} }
// ── Credential storage (OS keychain) ──────────────────────────────────────── // ── Credential storage (OS keychain) ────────────────────────────────────────
@@ -4791,10 +4889,14 @@ fn git_lfs_status_for_repo(repo: &Path) -> Result<GitLfsStatus, String> {
let bundled = bundled_git_lfs_path().is_some(); let bundled = bundled_git_lfs_path().is_some();
let version = git_lfs_version(); let version = git_lfs_version();
let available = version.is_some(); let available = version.is_some();
let attribute_patterns = lfs_patterns_from_attributes(repo);
let patterns = if available { let patterns = if available {
git_lfs_patterns(repo).unwrap_or_else(|_| lfs_patterns_from_attributes(repo)) match git_lfs_patterns(repo) {
Ok(reported_patterns) => merge_lfs_patterns(attribute_patterns, reported_patterns),
Err(_) => attribute_patterns,
}
} else { } else {
lfs_patterns_from_attributes(repo) attribute_patterns
}; };
let files = if available { let files = if available {
git_lfs_files(repo).unwrap_or_default() git_lfs_files(repo).unwrap_or_default()
@@ -4830,11 +4932,75 @@ fn ensure_git_lfs_available() -> Result<String, String> {
}) })
} }
fn activate_git_lfs_for_repo(repo: &Path) -> Result<(), String> {
ensure_git_lfs_available()?;
run_git(repo, ["lfs", "install", "--local"])?;
ensure_root_gitattributes_not_ignored(repo)?;
Ok(())
}
fn git_path_is_ignored(repo: &Path, path: &str) -> Result<bool, String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["check-ignore", "--quiet", "--no-index", "--", path])
.output()
.map_err(|err| format!("Could not inspect Git ignore rules: {err}"))?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(format!(
"Could not inspect Git ignore rules: {}",
command_output_details(&output)
)),
}
}
fn ensure_root_gitattributes_not_ignored(repo: &Path) -> Result<bool, String> {
const GITATTRIBUTES_PATH: &str = ".gitattributes";
const GITATTRIBUTES_EXCEPTION: &str = "!/.gitattributes";
if !git_path_is_ignored(repo, GITATTRIBUTES_PATH)? {
return Ok(false);
}
// The last matching ignore rule wins. Always append here, even if an
// earlier exception exists and is overridden by a later ignore rule.
append_gitignore_pattern_to_file(repo, GITATTRIBUTES_EXCEPTION, false)?;
if git_path_is_ignored(repo, GITATTRIBUTES_PATH)? {
return Err(
"`.gitattributes` is still ignored. Check higher-priority Git ignore rules."
.to_string(),
);
}
Ok(true)
}
fn repository_uses_lfs(repo: &Path) -> bool { fn repository_uses_lfs(repo: &Path) -> bool {
!lfs_patterns_from_attributes(repo).is_empty() !lfs_patterns_from_attributes(repo).is_empty()
|| git_lfs_files(repo).is_ok_and(|files| !files.is_empty()) || git_lfs_files(repo).is_ok_and(|files| !files.is_empty())
} }
fn sync_git_lfs_objects_if_needed(
repo: &Path,
remote: Option<&str>,
username: Option<&str>,
password: Option<&str>,
install_local: bool,
) -> Result<(), String> {
if !repository_uses_lfs(repo) {
return Ok(());
}
if install_local {
activate_git_lfs_for_repo(repo)?;
} else {
ensure_git_lfs_available()?;
}
pull_git_lfs_objects(repo, remote, username, password)
}
fn pull_git_lfs_objects( fn pull_git_lfs_objects(
repo: &Path, repo: &Path,
remote: Option<&str>, remote: Option<&str>,
@@ -4923,6 +5089,29 @@ fn git_lfs_patterns(repo: &Path) -> Result<Vec<GitLfsPattern>, String> {
Ok(parsed.patterns) Ok(parsed.patterns)
} }
fn merge_lfs_patterns(
mut attribute_patterns: Vec<GitLfsPattern>,
reported_patterns: Vec<GitLfsPattern>,
) -> Vec<GitLfsPattern> {
for reported in reported_patterns {
if let Some(existing) = attribute_patterns.iter_mut().find(|existing| {
existing.source == reported.source && existing.pattern == reported.pattern
}) {
*existing = reported;
} else {
attribute_patterns.push(reported);
}
}
attribute_patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
attribute_patterns
.dedup_by(|left, right| left.source == right.source && left.pattern == right.pattern);
attribute_patterns
}
fn git_lfs_files(repo: &Path) -> Result<Vec<GitLfsFile>, String> { fn git_lfs_files(repo: &Path) -> Result<Vec<GitLfsFile>, String> {
let json_output = run_git(repo, ["lfs", "ls-files", "--long", "--size", "--json"]); let json_output = run_git(repo, ["lfs", "ls-files", "--long", "--size", "--json"]);
if let Ok(output) = json_output { if let Ok(output) = json_output {
@@ -4965,7 +5154,12 @@ fn parse_git_lfs_file_line(line: &str) -> Option<GitLfsFile> {
} }
fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> { fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> {
let Ok(output) = run_git( let mut patterns = Vec::new();
// Read the repository root explicitly. `git lfs track` writes here even
// when .gitattributes is still untracked or excluded by an ignore rule.
read_lfs_patterns_from_file(repo, ".gitattributes", &mut patterns);
if let Ok(output) = run_git(
repo, repo,
[ [
"ls-files", "ls-files",
@@ -4974,16 +5168,16 @@ fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> {
"--others", "--others",
"--exclude-standard", "--exclude-standard",
], ],
) else { ) {
return Vec::new();
};
let mut patterns = Vec::new();
for path in parse_nul_paths(&output) { for path in parse_nul_paths(&output) {
if Path::new(&path).file_name() != Some(OsStr::new(".gitattributes")) { if path.replace('\\', "/") == ".gitattributes"
|| Path::new(&path).file_name() != Some(OsStr::new(".gitattributes"))
{
continue; continue;
} }
read_lfs_patterns_from_file(repo, &path, &mut patterns); read_lfs_patterns_from_file(repo, &path, &mut patterns);
} }
}
if let Ok(info_path) = run_git(repo, ["rev-parse", "--git-path", "info/attributes"]) { 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 = PathBuf::from(String::from_utf8_lossy(&info_path).trim().to_string());
@@ -5279,7 +5473,23 @@ fn clone_repository_core(
run_git_clone(remote_url.trim(), &target, username, password)?; run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?; let repo = resolve_repo(&target.to_string_lossy())?;
repository_bundle_for_repo(&repo, commit_limit) let lfs_warning = sync_git_lfs_objects_if_needed(
&repo,
Some("origin"),
username,
password,
true,
)
.err()
.map(|error| {
format!(
"Repository cloned, but Git LFS objects could not be downloaded automatically: {error}"
)
});
let mut bundle = repository_bundle_for_repo(&repo, commit_limit)?;
bundle.warning = lfs_warning;
Ok(bundle)
} }
fn clone_target_path( fn clone_target_path(
@@ -6308,6 +6518,173 @@ fn validate_files(files: &[String]) -> Result<(), String> {
Ok(()) Ok(())
} }
fn normalize_gitignore_target(target: &str) -> Result<String, String> {
if target.is_empty() || target.trim().is_empty() {
return Err("Ignore target must not be empty.".to_string());
}
if target
.chars()
.any(|character| matches!(character, '\0' | '\r' | '\n'))
{
return Err("Ignore target contains an unsupported control character.".to_string());
}
let normalized = target.replace('\\', "/");
if normalized.starts_with('/') || normalized.as_bytes().get(1) == Some(&b':') {
return Err("Ignore target must be relative to the repository.".to_string());
}
let segments: Vec<&str> = normalized.trim_end_matches('/').split('/').collect();
if segments.is_empty()
|| segments
.iter()
.any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
{
return Err("Ignore target must be a normalized repository path.".to_string());
}
if segments[0].eq_ignore_ascii_case(".git") {
return Err("The repository metadata directory cannot be ignored.".to_string());
}
Ok(segments.join("/"))
}
fn escape_gitignore_literal(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
if matches!(
character,
'\\' | '*' | '?' | '[' | ']' | '#' | '!' | ' ' | '\t'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
fn gitignore_extension(target: &str) -> Option<&str> {
let name = target.rsplit('/').next()?;
let separator = name.rfind('.')?;
(separator > 0 && separator < name.len() - 1).then(|| &name[separator + 1..])
}
fn gitignore_pattern(target: &str, kind: GitIgnoreKind) -> Result<String, String> {
match kind {
GitIgnoreKind::File => Ok(format!("/{}", escape_gitignore_literal(target))),
GitIgnoreKind::Folder => Ok(format!("/{}/", escape_gitignore_literal(target))),
GitIgnoreKind::Extension => {
let extension = gitignore_extension(target)
.ok_or_else(|| "The selected file has no extension to ignore.".to_string())?;
Ok(format!("*.{}", escape_gitignore_literal(extension)))
}
}
}
fn gitignore_target_matches(kind: GitIgnoreKind, target: &str, candidate: &str) -> bool {
match kind {
GitIgnoreKind::File => candidate == target,
GitIgnoreKind::Folder => candidate
.strip_prefix(target)
.is_some_and(|remainder| remainder.starts_with('/')),
GitIgnoreKind::Extension => gitignore_extension(target)
.zip(gitignore_extension(candidate))
.is_some_and(|(selected, current)| selected == current),
}
}
fn append_gitignore_pattern(repo: &Path, pattern: &str) -> Result<bool, String> {
append_gitignore_pattern_to_file(repo, pattern, true)
}
fn append_gitignore_pattern_to_file(
repo: &Path,
pattern: &str,
skip_if_present: bool,
) -> Result<bool, String> {
let gitignore = repo.join(".gitignore");
match fs::symlink_metadata(&gitignore) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err("Refusing to update a symlinked .gitignore file.".to_string());
}
Ok(metadata) if metadata.is_dir() => {
return Err(".gitignore is a directory, not a file.".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("Could not inspect .gitignore: {error}")),
}
let existing = match fs::read(&gitignore) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(format!("Could not read .gitignore: {error}")),
};
let text = std::str::from_utf8(&existing)
.map_err(|_| ".gitignore is not valid UTF-8 and cannot be updated safely.".to_string())?;
if skip_if_present
&& text
.lines()
.any(|line| line.trim_end_matches('\r') == pattern)
{
return Ok(false);
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore)
.map_err(|error| format!("Could not open .gitignore: {error}"))?;
if !existing.is_empty() && !existing.ends_with(b"\n") {
file.write_all(b"\n")
.map_err(|error| format!("Could not update .gitignore: {error}"))?;
}
file.write_all(pattern.as_bytes())
.and_then(|_| file.write_all(b"\n"))
.map_err(|error| format!("Could not update .gitignore: {error}"))?;
Ok(true)
}
fn add_to_gitignore_for_repo(
repo: &Path,
target: &str,
kind: GitIgnoreKind,
) -> Result<GitStatus, String> {
let target = normalize_gitignore_target(target)?;
let pattern = gitignore_pattern(&target, kind)?;
let status = status_for_repo(repo)?;
let staged_additions: Vec<String> = status
.files
.iter()
.filter(|file| {
file.staged == Some(FileStatusKind::Added)
&& file.old_path.is_none()
&& gitignore_target_matches(kind, &target, &file.path)
})
.map(|file| file.path.clone())
.collect();
append_gitignore_pattern(repo, &pattern)?;
if !staged_additions.is_empty() {
unstage_selected_files(repo, &status.files, &staged_additions)?;
}
status_for_repo(repo)
}
fn untrack_paths_for_repo(repo: &Path, targets: &[String]) -> Result<GitStatus, String> {
validate_files(targets)?;
if !targets.is_empty() {
// --cached keeps every working-tree file in place. Force only permits
// index removal when the staged/worktree content differs from HEAD.
run_git_with_paths(
repo,
&["rm", "-r", "--cached", "--force", "--ignore-unmatch"],
targets,
)?;
}
status_for_repo(repo)
}
fn write_temp_patch(patch: &str) -> Result<PathBuf, String> { fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed); let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
@@ -6998,6 +7375,91 @@ mod tests {
assert!(!file.checkout); assert!(!file.checkout);
} }
#[test]
fn merges_attribute_patterns_when_git_lfs_reports_none() {
let patterns = merge_lfs_patterns(
vec![GitLfsPattern {
pattern: "*.prt".to_string(),
source: ".gitattributes".to_string(),
lockable: false,
tracked: true,
}],
Vec::new(),
);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pattern, "*.prt");
}
#[test]
fn reads_ignored_root_gitattributes_for_lfs_patterns() {
let repo = init_temp_repo("ignored_root_lfs_attributes");
fs::write(repo.path.join(".gitignore"), ".gitattributes\n")
.expect("ignore fixture should be written");
fs::write(
repo.path.join(".gitattributes"),
"*.prt filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
let patterns = lfs_patterns_from_attributes(&repo.path);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pattern, "*.prt");
assert_eq!(patterns[0].source, ".gitattributes");
}
#[test]
fn lfs_activation_unignores_root_gitattributes() {
let repo = init_temp_repo("unignore_lfs_attributes");
fs::write(repo.path.join(".gitignore"), ".gitattributes\n")
.expect("ignore fixture should be written");
assert!(
ensure_root_gitattributes_not_ignored(&repo.path)
.expect("LFS activation should unignore .gitattributes")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
".gitattributes\n!/.gitattributes\n"
);
assert!(
!git_path_is_ignored(&repo.path, ".gitattributes")
.expect("ignore state should be readable")
);
assert!(
!ensure_root_gitattributes_not_ignored(&repo.path)
.expect("the existing exception should remain unchanged")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
".gitattributes\n!/.gitattributes\n"
);
}
#[test]
fn lfs_activation_moves_an_overridden_gitattributes_exception_to_the_end() {
let repo = init_temp_repo("refresh_lfs_attributes_exception");
fs::write(
repo.path.join(".gitignore"),
"!/.gitattributes\n.gitattributes\n",
)
.expect("ignore fixture should be written");
assert!(
ensure_root_gitattributes_not_ignored(&repo.path)
.expect("LFS activation should restore the final exception")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"!/.gitattributes\n.gitattributes\n!/.gitattributes\n"
);
assert!(
!git_path_is_ignored(&repo.path, ".gitattributes")
.expect("ignore state should be readable")
);
}
#[test] #[test]
fn validates_git_lfs_patterns() { fn validates_git_lfs_patterns() {
assert_eq!(validate_lfs_pattern(" *.psd ").unwrap(), "*.psd"); assert_eq!(validate_lfs_pattern(" *.psd ").unwrap(), "*.psd");
@@ -7020,6 +7482,30 @@ mod tests {
assert!(repository_uses_lfs(&repo.path)); assert!(repository_uses_lfs(&repo.path));
} }
#[test]
fn clone_lfs_sync_installs_local_filters_before_download() {
let repo = init_temp_repo("clone_lfs_local_setup");
fs::write(
repo.path.join(".gitattributes"),
"*.bin filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
// A repository without a remote may reject the pull, but clone setup
// must already be complete before that network step is attempted.
let _ = sync_git_lfs_objects_if_needed(&repo.path, None, None, None, true);
assert!(
run_git(
&repo.path,
["config", "--local", "--get", "filter.lfs.process"]
)
.is_ok(),
"clone sync should configure repository-local LFS filters"
);
assert!(lfs_hook_installed(&repo.path));
}
#[test] #[test]
#[cfg_attr( #[cfg_attr(
windows, windows,
@@ -7324,6 +7810,66 @@ mod tests {
assert!(bundle.status.clean); assert!(bundle.status.clean);
assert_eq!(bundle.commits.len(), 1); assert_eq!(bundle.commits.len(), 1);
assert!(bundle.files.iter().any(|file| file.path == "old.txt")); assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
assert!(bundle.warning.is_none());
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local LFS clone tests with a sh signal pipe error"
)]
fn clone_repository_core_activates_and_downloads_lfs() {
let source = init_temp_repo("lfs_clone_source");
let remote = init_bare_temp_repo("lfs_clone_remote");
let parent = temp_dir("lfs_clone_parent");
run_git_test(&source.path, ["lfs", "install", "--local"]);
run_git_test(&source.path, ["lfs", "track", "*.bin"]);
fs::write(source.path.join("asset.bin"), b"cloned LFS payload\n")
.expect("LFS fixture should be written");
run_git_test(&source.path, ["add", ".gitattributes", "asset.bin"]);
run_git_test(&source.path, ["commit", "-q", "-m", "add LFS object"]);
run_git_test(
&source.path,
[
OsString::from("remote"),
OsString::from("add"),
OsString::from("origin"),
remote.path.as_os_str().to_owned(),
],
);
let branch = git_output_test(&source.path, ["branch", "--show-current"]);
run_git_test(&source.path, ["push", "-q", "-u", "origin", &branch]);
run_git_test(
&remote.path,
["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
);
let bundle = clone_repository_core(
remote.path.to_str().expect("remote path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("LFS repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert!(bundle.warning.is_none());
assert_eq!(
fs::read(cloned_repo.join("asset.bin")).expect("LFS object should be downloaded"),
b"cloned LFS payload\n"
);
assert!(
run_git(
&cloned_repo,
["config", "--local", "--get", "filter.lfs.process"]
)
.is_ok(),
"clone should configure repository-local LFS filters"
);
assert!(lfs_hook_installed(&cloned_repo));
} }
#[test] #[test]
@@ -8058,6 +8604,36 @@ mod tests {
); );
} }
#[test]
fn detects_lfs_http_413_and_builds_scoped_http_1_1_retry() {
assert!(is_lfs_http_413_error(
"Git command failed: LFS: Client error https://example.test/object from HTTP 413\nerror: failed to push some refs"
));
assert!(!is_lfs_http_413_error(
"error: failed to push some refs (fetch first)"
));
let retry_args = push_args_with_http_1_1(vec![
OsString::from("push"),
OsString::from("--force-with-lease"),
OsString::from("origin"),
])
.into_iter()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(
retry_args,
vec![
"-c",
"http.version=HTTP/1.1",
"push",
"--force-with-lease",
"origin"
]
);
}
#[test] #[test]
fn credential_payload_remains_backward_compatible() { fn credential_payload_remains_backward_compatible() {
let legacy: StoredCredential = let legacy: StoredCredential =
@@ -8970,4 +9546,121 @@ mod tests {
let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#; let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#;
assert!(parse_ai_commit_plan(duplicate, &files).is_err()); assert!(parse_ai_commit_plan(duplicate, &files).is_err());
} }
#[test]
fn gitignore_file_rule_escapes_literal_path_characters() {
let repo = init_temp_repo("gitignore_literal_path");
fs::create_dir_all(repo.path.join("generated files"))
.expect("test directory should be created");
let target = "generated files/[draft]!.log";
fs::write(repo.path.join(target), "temporary\n").expect("test file should be written");
add_to_gitignore_for_repo(&repo.path, target, GitIgnoreKind::File)
.expect("file should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"/generated\\ files/\\[draft\\]\\!.log\n"
);
assert_eq!(
git_output_test(&repo.path, ["check-ignore", target]),
target
);
}
#[test]
fn gitignore_extension_rule_unstages_all_matching_new_files_without_duplicates() {
let repo = init_temp_repo("gitignore_extension");
fs::create_dir_all(repo.path.join("build")).expect("build directory should be created");
fs::write(repo.path.join("build/result.log"), "result\n")
.expect("nested log should be written");
fs::write(repo.path.join("debug.log"), "debug\n").expect("root log should be written");
run_git_test(&repo.path, ["add", "build/result.log", "debug.log"]);
let status =
add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension)
.expect("extension should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"*.log\n"
);
assert!(status.files.iter().all(|file| !file.path.ends_with(".log")));
assert!(git_output_test(&repo.path, ["ls-files"]).is_empty());
assert_eq!(
git_output_test(
&repo.path,
["check-ignore", "build/result.log", "debug.log"]
),
"build/result.log\ndebug.log"
);
add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension)
.expect("existing rule should be accepted");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"*.log\n"
);
}
#[test]
fn gitignore_folder_rule_only_unstages_files_below_the_selected_folder() {
let repo = init_temp_repo("gitignore_folder");
fs::create_dir_all(repo.path.join("cache/nested"))
.expect("cache directory should be created");
fs::create_dir_all(repo.path.join("cache-old"))
.expect("similarly named directory should be created");
fs::write(repo.path.join("cache/nested/result.tmp"), "cached\n")
.expect("cached file should be written");
fs::write(repo.path.join("cache-old/keep.tmp"), "keep\n")
.expect("kept file should be written");
run_git_test(
&repo.path,
["add", "cache/nested/result.tmp", "cache-old/keep.tmp"],
);
add_to_gitignore_for_repo(&repo.path, "cache", GitIgnoreKind::Folder)
.expect("folder should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"/cache/\n"
);
assert_eq!(
git_output_test(&repo.path, ["ls-files"]),
"cache-old/keep.tmp"
);
assert_eq!(
git_output_test(&repo.path, ["check-ignore", "cache/nested/result.tmp"]),
"cache/nested/result.tmp"
);
}
#[test]
fn untrack_paths_keep_worktree_files_and_only_remove_the_selected_scope() {
let repo = init_temp_repo("untrack_paths");
fs::create_dir_all(repo.path.join("generated/nested"))
.expect("tracked directory should be created");
fs::write(repo.path.join("generated/nested/output.bin"), "original\n")
.expect("tracked file should be written");
fs::write(repo.path.join("keep.txt"), "keep\n").expect("kept file should be written");
run_git_test(&repo.path, ["add", "."]);
run_git_test(&repo.path, ["commit", "-m", "initial"]);
fs::write(
repo.path.join("generated/nested/output.bin"),
"local change\n",
)
.expect("tracked file should be modified");
let targets = vec!["generated".to_string()];
untrack_paths_for_repo(&repo.path, &targets).expect("folder should be untracked");
assert_eq!(
fs::read_to_string(repo.path.join("generated/nested/output.bin"))
.expect("working-tree file should remain"),
"local change\n"
);
assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt");
assert!(repo.path.join("keep.txt").is_file());
}
} }
+145 -23
View File
@@ -10,8 +10,8 @@ use external_tools::{
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool, detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
}; };
use git::{ use git::{
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch, SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status, commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
@@ -30,7 +30,7 @@ use git::{
revert_commit, run_sequence_editor_if_requested, search_code_introductions, revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply, set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote, untrack_paths, update_remote,
}; };
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
@@ -39,11 +39,68 @@ use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
struct StartupRepository(Mutex<Option<String>>); struct StartupRepository(Mutex<Option<String>>);
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct StartupCloneRequest {
remote_url: String,
parent_path: String,
directory_name: String,
}
struct StartupClone(Mutex<Option<StartupCloneRequest>>);
fn resolve_startup_path(path: &str, cwd: &Path) -> Option<PathBuf> {
let path = path.trim();
if path.is_empty() {
return None;
}
let path = PathBuf::from(path);
Some(if path.is_absolute() {
path
} else {
cwd.join(path)
})
}
fn clone_request_from_args(
args: impl IntoIterator<Item = String>,
cwd: &Path,
) -> Option<StartupCloneRequest> {
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let remote_url = if arg == "clone" || arg == "--clone" {
args.next()
} else {
arg.strip_prefix("--clone=").map(ToString::to_string)
};
let Some(remote_url) = remote_url.filter(|value| !value.trim().is_empty()) else {
continue;
};
let target = resolve_startup_path(&args.next()?, cwd)?;
let directory_name = target.file_name()?.to_string_lossy().trim().to_string();
let parent_path = target.parent()?.to_string_lossy().into_owned();
if directory_name.is_empty() || parent_path.trim().is_empty() {
return None;
}
return Some(StartupCloneRequest {
remote_url,
parent_path,
directory_name,
});
}
None
}
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> { fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
let mut args = args.into_iter(); let mut args = args.into_iter();
let mut repository = None; let mut repository = None;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") {
return None;
}
if arg == "--repo" { if arg == "--repo" {
repository = args.next(); repository = args.next();
break; break;
@@ -58,27 +115,24 @@ fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path)
} }
} }
repository.filter(|path| !path.trim().is_empty()).map(|path| { repository
let path = PathBuf::from(path); .and_then(|path| resolve_startup_path(&path, cwd))
if path.is_absolute() { .map(|path| path.to_string_lossy().into_owned())
path
} else {
cwd.join(path)
}
.to_string_lossy()
.into_owned()
})
} }
#[cfg(test)] #[cfg(test)]
mod startup_repository_tests { mod startup_repository_tests {
use super::repository_path_from_args; use super::{clone_request_from_args, repository_path_from_args};
use std::path::Path; use std::path::Path;
#[test] #[test]
fn accepts_direct_relative_repository_path() { fn accepts_direct_relative_repository_path() {
let path = repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user")); let path =
assert_eq!(path.as_deref(), Some("/home/user/projects/repo")); repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
assert_eq!(
path.map(|value| value.replace('\\', "/")),
Some("/home/user/projects/repo".to_string())
);
} }
#[test] #[test]
@@ -96,7 +150,60 @@ mod startup_repository_tests {
["--repo=projects/repo".to_string()], ["--repo=projects/repo".to_string()],
Path::new("/home/user"), Path::new("/home/user"),
); );
assert_eq!(path.as_deref(), Some("/home/user/projects/repo")); assert_eq!(
path.map(|value| value.replace('\\', "/")),
Some("/home/user/projects/repo".to_string())
);
}
#[test]
fn accepts_clone_command_with_exact_relative_target() {
let request = clone_request_from_args(
[
"clone".to_string(),
"https://example.com/team/project.git".to_string(),
"clones/local-copy".to_string(),
],
Path::new("/home/user"),
)
.expect("clone request should be parsed");
assert_eq!(request.remote_url, "https://example.com/team/project.git");
assert_eq!(request.directory_name, "local-copy");
assert_eq!(request.parent_path.replace('\\', "/"), "/home/user/clones");
}
#[test]
fn accepts_clone_option_and_clone_equals_option() {
for args in [
vec![
"--clone".to_string(),
"git@example.com:team/project.git".to_string(),
"/projects/project".to_string(),
],
vec![
"--clone=git@example.com:team/project.git".to_string(),
"/projects/project".to_string(),
],
] {
let request = clone_request_from_args(args, Path::new("/home/user"))
.expect("clone option should be parsed");
assert_eq!(request.directory_name, "project");
assert_eq!(request.parent_path.replace('\\', "/"), "/projects");
}
}
#[test]
fn repository_parser_does_not_treat_clone_values_as_repository_paths() {
let path = repository_path_from_args(
[
"--clone".to_string(),
"https://example.com/team/project.git".to_string(),
"clones/project".to_string(),
],
Path::new("/home/user"),
);
assert!(path.is_none());
} }
} }
@@ -105,6 +212,11 @@ fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option
state.0.lock().ok()?.take() state.0.lock().ok()?.take()
} }
#[tauri::command]
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
state.0.lock().ok()?.take()
}
struct ConsoleLogger; struct ConsoleLogger;
impl log::Log for ConsoleLogger { impl log::Log for ConsoleLogger {
@@ -167,14 +279,20 @@ async fn main() {
return; return;
} }
let startup_repository = repository_path_from_args( let startup_args: Vec<String> = std::env::args().skip(1).collect();
std::env::args().skip(1), let startup_cwd = std::env::current_dir().unwrap_or_default();
&std::env::current_dir().unwrap_or_default(), let startup_clone = clone_request_from_args(startup_args.clone(), &startup_cwd);
); let startup_repository = repository_path_from_args(startup_args, &startup_cwd);
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
if let Some(path) = repository_path_from_args(args.into_iter().skip(1), Path::new(&cwd)) { let args: Vec<String> = args.into_iter().skip(1).collect();
if let Some(request) = clone_request_from_args(args.clone(), Path::new(&cwd)) {
if let Ok(mut pending) = app.state::<StartupClone>().0.lock() {
*pending = Some(request);
}
let _ = app.emit("open-startup-repository", ());
} else if let Some(path) = repository_path_from_args(args, Path::new(&cwd)) {
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() { if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
*pending = Some(path); *pending = Some(path);
} }
@@ -195,6 +313,7 @@ async fn main() {
.build(), .build(),
) )
.manage(StartupRepository(Mutex::new(startup_repository))) .manage(StartupRepository(Mutex::new(startup_repository)))
.manage(StartupClone(Mutex::new(startup_clone)))
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new()) .manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init()); .plugin(tauri_plugin_dialog::init());
@@ -254,6 +373,8 @@ async fn main() {
cherry_pick_abort, cherry_pick_abort,
stage_files, stage_files,
unstage_files, unstage_files,
add_to_gitignore,
untrack_paths,
stash_push, stash_push,
stash_apply, stash_apply,
stash_pop, stash_pop,
@@ -316,7 +437,8 @@ async fn main() {
set_telemetry_enabled, set_telemetry_enabled,
emit_frontend_log, emit_frontend_log,
emit_frontend_span, emit_frontend_span,
take_startup_repository take_startup_repository,
take_startup_clone
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.8.5", "version": "2026.8.6",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev", "beforeDevCommand": "npm run prepare:lfs && npm run dev",
+46 -4
View File
@@ -36,6 +36,7 @@
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import { import {
addToGitignore,
amendCommit, amendCommit,
addRemote, addRemote,
addWorktree, addWorktree,
@@ -137,6 +138,7 @@
undoLastCommit, undoLastCommit,
unlockWorktree, unlockWorktree,
untrackGitLfsPattern, untrackGitLfsPattern,
untrackPaths,
unstageFiles, unstageFiles,
} from "./lib/git"; } from "./lib/git";
@@ -161,6 +163,7 @@
GitCommitComparison, GitCommitComparison,
GitDiffFile, GitDiffFile,
GitFileStatus, GitFileStatus,
GitIgnoreKind,
GitLfsStatus, GitLfsStatus,
GitRepositoryFile, GitRepositoryFile,
GitRemote, GitRemote,
@@ -285,6 +288,7 @@
let repoPath = ""; let repoPath = "";
let startupReady = false; let startupReady = false;
let pendingStartupRepoPath = ""; let pendingStartupRepoPath = "";
let pendingStartupCloneRequest: CloneRequest | null = null;
let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined; let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined;
let unlistenStartupRepository: (() => void) | undefined; let unlistenStartupRepository: (() => void) | undefined;
let activeRepoPath = ""; let activeRepoPath = "";
@@ -680,13 +684,21 @@
async function receiveStartupRepository() { async function receiveStartupRepository() {
try { try {
const path = await tracedInvoke<string | null>("take_startup_repository"); const [path, cloneRequest] = await Promise.all([
if (path) pendingStartupRepoPath = path; tracedInvoke<string | null>("take_startup_repository"),
tracedInvoke<CloneRequest | null>("take_startup_clone"),
]);
if (cloneRequest) {
pendingStartupCloneRequest = cloneRequest;
pendingStartupRepoPath = "";
} else if (path) {
pendingStartupRepoPath = path;
}
} catch { } catch {
return; return;
} }
if (!startupReady || !pendingStartupRepoPath) return; if (!startupReady || (!pendingStartupCloneRequest && !pendingStartupRepoPath)) return;
if (isBusy) { if (isBusy) {
if (!startupRepositoryRetryTimer) { if (!startupRepositoryRetryTimer) {
startupRepositoryRetryTimer = setTimeout(() => { startupRepositoryRetryTimer = setTimeout(() => {
@@ -696,6 +708,12 @@
} }
return; return;
} }
if (pendingStartupCloneRequest) {
const request = pendingStartupCloneRequest;
pendingStartupCloneRequest = null;
await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName);
return;
}
const path = pendingStartupRepoPath; const path = pendingStartupRepoPath;
pendingStartupRepoPath = ""; pendingStartupRepoPath = "";
await openRepo(path); await openRepo(path);
@@ -2060,7 +2078,6 @@
function isNonFastForwardPushError(message: string): boolean { function isNonFastForwardPushError(message: string): boolean {
const value = message.toLowerCase(); const value = message.toLowerCase();
return value.includes("non-fast-forward") return value.includes("non-fast-forward")
|| value.includes("failed to push some refs")
|| value.includes("tip of your current branch is behind") || value.includes("tip of your current branch is behind")
|| value.includes("fetch first"); || value.includes("fetch first");
} }
@@ -2406,6 +2423,7 @@
activeView = "repository"; activeView = "repository";
cloneDialogOpen = false; cloneDialogOpen = false;
pendingClone = null; pendingClone = null;
errorMessage = bundle.warning ?? "";
if (credDialogAction === "clone") { if (credDialogAction === "clone") {
credDialogOpen = false; credDialogOpen = false;
credDialogAction = null; credDialogAction = null;
@@ -2416,6 +2434,7 @@
trackEvent("repository_cloned", { trackEvent("repository_cloned", {
changed_files: bundle.status.files.length, changed_files: bundle.status.files.length,
has_upstream: bundle.status.upstream ? 1 : 0, has_upstream: bundle.status.upstream ? 1 : 0,
lfs_warning: bundle.warning ? 1 : 0,
}); });
} catch (error) { } catch (error) {
const rawMessage = errorToMessage(error); const rawMessage = errorToMessage(error);
@@ -3947,6 +3966,25 @@
}); });
} }
async function ignoreStatusTarget(target: string, kind: GitIgnoreKind) {
if (!activeRepoPath || !target) return;
const description = kind === "folder" ? "folder" : kind === "extension" ? "file extension" : "file";
await runOperation(`Ignoring ${description}`, async () => {
applyStatus(await addToGitignore(activeRepoPath, target, kind));
await refreshExplorerFiles(activeRepoPath);
trackEvent("gitignore_rule_added", { kind });
});
}
async function stopTrackingTarget(target: string, kind: "file" | "folder") {
if (!activeRepoPath || !target) return;
await runOperation(`Stopping tracking for ${kind}`, async () => {
applyStatus(await untrackPaths(activeRepoPath, [target]));
await refreshExplorerFiles(activeRepoPath);
trackEvent("git_paths_untracked", { kind });
});
}
function discardFiles(files: GitFileStatus[], staged: boolean) { function discardFiles(files: GitFileStatus[], staged: boolean) {
if (!activeRepoPath || isBusy || files.length === 0) return; if (!activeRepoPath || isBusy || files.length === 0) return;
pendingDiscard = { kind: "file", files, staged }; pendingDiscard = { kind: "file", files, staged };
@@ -5309,6 +5347,8 @@
onExternalDiff={compareExplorerFileExternally} onExternalDiff={compareExplorerFileExternally}
onFileHistory={openFileHistoryDialog} onFileHistory={openFileHistoryDialog}
onBlame={openBlame} onBlame={openBlame}
onIgnore={ignoreStatusTarget}
onStopTracking={stopTrackingTarget}
collapsed={explorerPanelCollapsed} collapsed={explorerPanelCollapsed}
onToggleCollapsed={toggleExplorerPanelCollapsed} onToggleCollapsed={toggleExplorerPanelCollapsed}
/> />
@@ -5378,6 +5418,8 @@
onDiscard={discardFiles} onDiscard={discardFiles}
onDiscardMany={discardChanges} onDiscardMany={discardChanges}
onStash={stashStatusFiles} onStash={stashStatusFiles}
onIgnore={ignoreStatusTarget}
onStopTracking={stopTrackingTarget}
onPatch={openPreferredFileDiff} onPatch={openPreferredFileDiff}
onStageAll={stageAllFiles} onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles} onUnstageAll={unstageAllFiles}
+72 -1
View File
@@ -2350,8 +2350,20 @@
.status-context-menu, .status-context-menu,
.repo-tab-context-menu { position: fixed; } .repo-tab-context-menu { position: fixed; }
.explorer-context-menu {
box-sizing: border-box;
width: min(240px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow-y: auto;
overflow-x: hidden;
}
.status-context-menu { .status-context-menu {
box-sizing: border-box;
width: min(280px, calc(100vw - 16px)); width: min(280px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow-y: auto;
overflow-x: hidden;
padding: 6px; padding: 6px;
border-color: color-mix(in srgb, var(--color-border) 78%, #5a8cf8); border-color: color-mix(in srgb, var(--color-border) 78%, #5a8cf8);
background: background:
@@ -2461,11 +2473,26 @@
} }
.status-context-menu button { .status-context-menu button {
box-sizing: border-box;
max-width: 100%;
min-height: 42px; min-height: 42px;
gap: 9px; gap: 9px;
padding: 6px 7px; padding: 6px 7px;
overflow: hidden;
} }
.explorer-context-menu button {
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorer-context-menu button svg { flex: 0 0 auto; }
.explorer-context-menu button.ignore { color: #91b59a; }
.explorer-context-menu button.untrack { color: #d7ad6d; }
.status-context-action-icon { .status-context-action-icon {
width: 26px; width: 26px;
height: 26px; height: 26px;
@@ -2475,17 +2502,26 @@
color: var(--color-ink-dim); color: var(--color-ink-dim);
} }
.status-context-action-copy { gap: 1px; } .status-context-action-copy {
gap: 1px;
overflow: hidden;
}
.status-context-action-copy strong { .status-context-action-copy strong {
overflow: hidden;
color: var(--color-ink-muted); color: var(--color-ink-muted);
font-size: 11.5px; font-size: 11.5px;
line-height: 1.25; line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
} }
.status-context-action-copy span { .status-context-action-copy span {
overflow: hidden;
color: var(--color-ink-faint); color: var(--color-ink-faint);
font-size: 9.5px; font-size: 9.5px;
font-weight: 500; font-weight: 500;
line-height: 1.25; line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
} }
.branch-context-menu button:hover:not(:disabled), .branch-context-menu button:hover:not(:disabled),
@@ -2506,8 +2542,18 @@
color: var(--color-ink); color: var(--color-ink);
} }
.status-context-action-icon.ignore {
color: #91b59a;
}
.status-context-action-icon.untrack {
color: #d7ad6d;
}
.branch-context-menu .menu-separator, .branch-context-menu .menu-separator,
.history-context-menu .menu-separator, .history-context-menu .menu-separator,
.explorer-context-menu .menu-separator,
.status-context-menu .menu-separator,
.repo-tab-context-menu .menu-separator { .repo-tab-context-menu .menu-separator {
height: 1px; height: 1px;
margin: 4px 3px; margin: 4px 3px;
@@ -4231,6 +4277,31 @@
.tool-surface-choice > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tool-surface-choice > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tool-surface-choice > button:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); } .tool-surface-choice > button:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-surface-choice > button.active { border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border)); color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); } .tool-surface-choice > button.active { border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border)); color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
.line-patch-header-actions { --line-patch-header-control-size: var(--ui-control-height); }
.line-patch-header-actions .tool-surface-choice {
height: var(--line-patch-header-control-size);
padding: 0;
overflow: hidden;
border-radius: var(--ui-radius-sm);
}
.line-patch-header-actions .tool-surface-choice > span {
display: inline-flex;
align-items: center;
align-self: stretch;
}
.line-patch-header-actions .tool-surface-choice > button {
height: var(--line-patch-header-control-size);
min-height: var(--line-patch-header-control-size);
border-radius: calc(var(--ui-radius-sm) - 1px);
}
.line-patch-header-actions > .btn-sm {
height: var(--line-patch-header-control-size);
min-height: var(--line-patch-header-control-size);
}
.line-patch-header-actions > .dialog-close {
width: var(--line-patch-header-control-size);
min-width: var(--line-patch-header-control-size);
}
.compare-restore { max-width: 170px; min-width: 0; } .compare-restore { max-width: 170px; min-width: 0; }
.compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+83 -29
View File
@@ -11,20 +11,24 @@
FileCog, FileCog,
FileImage, FileImage,
FileJson, FileJson,
FileMinus2,
FileSearch, FileSearch,
GitCompare, GitCompare,
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
FileType, FileType,
FileVideo, FileVideo,
FileX,
Folder, Folder,
FolderMinus,
FolderOpen, FolderOpen,
FolderX,
ExternalLink, ExternalLink,
History, History,
Terminal, Terminal,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons"; import { languageIconForPath } from "../languageIcons";
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types"; import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitIgnoreKind, GitRepositoryFile } from "../types";
import LanguageIcon from "./LanguageIcon.svelte"; import LanguageIcon from "./LanguageIcon.svelte";
interface Props { interface Props {
@@ -46,6 +50,8 @@
onExternalDiff: (node: ExplorerNode) => void; onExternalDiff: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void; onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void; onBlame: (node: ExplorerNode) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapsed?: () => void; onToggleCollapsed?: () => void;
} }
@@ -69,6 +75,8 @@
onExternalDiff = () => {}, onExternalDiff = () => {},
onFileHistory = () => {}, onFileHistory = () => {},
onBlame = () => {}, onBlame = () => {},
onIgnore = () => {},
onStopTracking = () => {},
collapsed = false, collapsed = false,
onToggleCollapsed = () => {}, onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
@@ -76,6 +84,7 @@
let contextNode = $state<ExplorerNode | null>(null); let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let contextMenuElement = $state<HTMLDivElement | null>(null);
const isGerman = $derived(language === "de"); const isGerman = $derived(language === "de");
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null { function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
@@ -183,14 +192,19 @@
return "text"; return "text";
} }
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) { function openNodeContextMenu(event: MouseEvent, node: ExplorerNode) {
if (node.kind !== "file") return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
contextNode = node; contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192)); contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 248));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220)); contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 320));
requestAnimationFrame(() => {
if (!contextMenuElement) return;
const bounds = contextMenuElement.getBoundingClientRect();
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - bounds.width - 8));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - bounds.height - 8));
});
} }
function closeFileContextMenu() { function closeFileContextMenu() {
@@ -232,6 +246,31 @@
onFileHistory(node); onFileHistory(node);
} }
function explorerFileNodes(node: ExplorerNode): ExplorerNode[] {
if (node.kind === "file") return [node];
return node.children.flatMap(explorerFileNodes);
}
function isIgnoreableExplorerFile(node: ExplorerNode): boolean {
return node.kind === "file" && !node.tracked && node.path.replace(/\\/g, "/").toLowerCase() !== ".gitignore";
}
function runContextIgnore(kind: GitIgnoreKind) {
const node = contextNode;
if (!node) return;
if (kind === "folder" && node.kind !== "folder") return;
if ((kind === "file" || kind === "extension") && node.kind !== "file") return;
closeFileContextMenu();
onIgnore(node.path, kind);
}
function runContextStopTracking() {
const node = contextNode;
if (!node) return;
closeFileContextMenu();
onStopTracking(node.path, node.kind);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu(); if (event.key === "Escape") closeFileContextMenu();
} }
@@ -239,6 +278,10 @@
let explorerTree = $derived(buildExplorerTree(repoFiles)); let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths)); let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder")); let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
let contextFiles = $derived(contextNode ? explorerFileNodes(contextNode) : []);
let contextCanIgnore = $derived(contextFiles.some(isIgnoreableExplorerFile));
let contextCanStopTracking = $derived(contextFiles.some((node) => node.tracked));
let contextIgnoreExtension = $derived(contextNode?.kind === "file" && contextCanIgnore ? extensionFor(contextNode.path) : "");
let selectedFileNode = $derived( let selectedFileNode = $derived(
selectedExplorerKind === "file" selectedExplorerKind === "file"
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null ? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
@@ -338,7 +381,7 @@
class:folder={node.kind === "folder"} class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`} style={`--depth: ${node.depth}`}
title={node.path} title={node.path}
oncontextmenu={(event) => openFileContextMenu(event, node)} oncontextmenu={(event) => openNodeContextMenu(event, node)}
> >
{#if node.kind === "folder"} {#if node.kind === "folder"}
<button <button
@@ -420,12 +463,14 @@
{#if contextNode} {#if contextNode}
<div <div
bind:this={contextMenuElement}
class="explorer-context-menu" class="explorer-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`} style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu" role="menu"
tabindex="-1" tabindex="-1"
aria-label={`Actions for ${contextNode.path}`} aria-label={`Actions for ${contextNode.path}`}
> >
{#if contextNode.kind === "file"}
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}> <button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
<FileCode size={14} aria-hidden="true" /> <FileCode size={14} aria-hidden="true" />
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`} {isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
@@ -434,35 +479,44 @@
<GitCompare size={14} aria-hidden="true" /> <GitCompare size={14} aria-hidden="true" />
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`} {isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
</button> </button>
<button <button type="button" role="menuitem" onclick={openContextFileHistory} disabled={!contextNode.tracked} title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}>
type="button"
role="menuitem"
onclick={openContextFileHistory}
disabled={!contextNode.tracked}
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
>
<History size={14} aria-hidden="true" /> <History size={14} aria-hidden="true" />
File history {isGerman ? "Dateiverlauf" : "File history"}
</button> </button>
<button <button type="button" role="menuitem" onclick={openContextFile} disabled={contextNode.status === "deleted"} title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}>
type="button"
role="menuitem"
onclick={openContextFile}
disabled={contextNode.status === "deleted"}
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
>
<ExternalLink size={14} aria-hidden="true" /> <ExternalLink size={14} aria-hidden="true" />
Open in Explorer {isGerman ? "Im Explorer öffnen" : "Open in Explorer"}
</button> </button>
<button <button type="button" role="menuitem" onclick={openContextBlame} disabled={!contextNode.tracked || contextNode.status === "deleted"} title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}>
type="button"
role="menuitem"
onclick={openContextBlame}
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<FileSearch size={14} aria-hidden="true" /> <FileSearch size={14} aria-hidden="true" />
Blame Blame
</button> </button>
{/if}
{#if contextCanStopTracking || contextCanIgnore}
<div class="menu-separator" role="separator"></div>
{/if}
{#if contextCanStopTracking}
<button class="untrack" type="button" role="menuitem" onclick={runContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
{#if contextNode.kind === "folder"}<FolderMinus size={14} aria-hidden="true" />{:else}<FileMinus2 size={14} aria-hidden="true" />{/if}
{isGerman ? `${contextNode.kind === "folder" ? "Ordner" : "Datei"} nicht mehr tracken` : `Stop tracking ${contextNode.kind}`}
</button>
{/if}
{#if contextCanIgnore && contextNode.kind === "file"}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("file")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/")} to .gitignore`}>
<FileX size={14} aria-hidden="true" />
{isGerman ? "Datei ignorieren" : "Ignore file"}
</button>
{#if contextIgnoreExtension}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("extension")} disabled={isBusy} title={`Add *.${contextIgnoreExtension} to .gitignore`}>
<FileType size={14} aria-hidden="true" />
{isGerman ? `Alle *.${contextIgnoreExtension}-Dateien ignorieren` : `Ignore all *.${contextIgnoreExtension} files`}
</button>
{/if}
{:else if contextCanIgnore && contextNode.kind === "folder"}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("folder")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/").replace(/\/+$/, "")}/ to .gitignore`}>
<FolderX size={14} aria-hidden="true" />
{isGerman ? "Ordner ignorieren" : "Ignore folder"}
</button>
{/if}
</div> </div>
{/if} {/if}
+38 -8
View File
@@ -503,8 +503,9 @@
{ {
id: "lfs-sync", id: "lfs-sync",
title: "LFS-Objekte synchronisieren", title: "LFS-Objekte synchronisieren",
summary: "Ein normaler Pull in Gitty prüft nach erfolgreicher Git-Synchronisierung automatisch auf LFS und lädt benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.", summary: "Clone und Pull prüfen in Gitty automatisch auf LFS und laden benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
steps: [ steps: [
"Neue Klone aus der Oberfläche sowie über --clone aktivieren LFS lokal und laden die Objekte von origin, bevor das Repository geöffnet wird.",
"Normales Push nutzt den LFS-Pre-push-Hook und lädt neue LFS-Objekte vor den Git-Referenzen hoch.", "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.", "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.", "Cache bereinigen entfernt sicher nicht mehr benötigte lokale Objekte; aktuell verwendete und noch nicht gepushte Inhalte bleiben erhalten.",
@@ -563,8 +564,9 @@
{ {
id: "lfs-sync", id: "lfs-sync",
title: "Synchronize LFS objects", title: "Synchronize LFS objects",
summary: "After a successful regular pull, Gitty automatically checks for LFS and downloads required objects with the same remote and credentials. A second pull is not necessary.", summary: "Clone and pull automatically check for LFS in Gitty and download required objects with the same remote and credentials. A second pull is not necessary.",
steps: [ steps: [
"Fresh clones from the UI and --clone activate LFS locally and download objects from origin before the repository is opened.",
"A normal push uses the LFS pre-push hook to upload new LFS objects before Git references are published.", "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.", "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.", "Prune cache safely removes unused local objects while retaining current and unpushed content.",
@@ -1520,6 +1522,20 @@
label: "Neu in Gitty", label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.", description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [ sections: [
{
id: "changelog-2026-8-6",
title: "Version 2026.8.6",
summary: "Dieses Wartungsrelease stabilisiert Git-LFS-Workflows vom Tracking über Clone und Pull bis zum Push großer Dateien nach Azure DevOps.",
steps: [
"LFS-Muster aus der .gitattributes im Repository-Stamm bleiben im LFS-Dialog sichtbar, auch wenn die Datei noch ungetrackt ist oder zuvor durch eine Ignore-Regel ausgeblendet wurde.",
"Beim Aktivieren von Git LFS und beim Hinzufügen eines Tracking-Musters stellt Gitty sicher, dass die .gitattributes nicht ignoriert wird. Nur wenn nötig, wird die gezielte Ausnahme !/.gitattributes am Ende der .gitignore ergänzt.",
"Clone und Pull verwenden für erkannte LFS-Repositories denselben Remote und dieselben Zugangsdaten auch zum Laden der LFS-Objekte. Neue Klone aktivieren Filter und Pre-Push-Hook automatisch.",
"Wenn Azure DevOps einen großen LFS-Upload über HTTP/2 mit HTTP 413 ablehnt, wiederholt Gitty den Push einmal mit einer nur für diesen Befehl geltenden HTTP/1.1-Konfiguration. Globale und Repository-Einstellungen bleiben unverändert.",
"LFS-, Größen- und andere allgemeine Push-Fehler werden nicht mehr als Non-Fast-Forward verwechselt. Der unnötige Ablauf „Pull vor Push“ mit anschließendem „Push after pull“ erscheint nur noch bei einem tatsächlichen veralteten lokalen Branch.",
"Der Tauri-Debug-Launcher entfernt ausschließlich bekannte nicht routende Test-Proxys aus dem Gitty-Unterprozess. Echte Benutzer- und Unternehmens-Proxys bleiben erhalten, sodass Remote- und LFS-Abläufe auch im Debug-Build testbar sind.",
],
note: "Die HTTP/1.1-Wiederholung greift nur nach einem LFS-Fehler 413. Änderungen an .gitattributes und .gitignore bleiben normale Repository-Änderungen und müssen committed und gepusht werden.",
},
{ {
id: "changelog-2026-8-5", id: "changelog-2026-8-5",
title: "Version 2026.8.5", title: "Version 2026.8.5",
@@ -1527,11 +1543,11 @@
steps: [ steps: [
"Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.", "Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.",
"LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.", "LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.",
"Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.", "Nach einem erfolgreichen Clone oder Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Neue Klone aktivieren LFS außerdem lokal, sodass kein zweiter manueller Pull erforderlich ist.",
"Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.", "Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.",
"Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.", "Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.",
"Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.", "Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Neue und ungetrackte Inhalte können im Changes-Bereich und im File Explorer als exakte Datei, kompletter Ordner oder Dateiendungs-Muster in die .gitignore übernommen werden; die Ordneroption erscheint nur beim Rechtsklick auf einen Ordner. Bereits getrackte Dateien und Ordner lassen sich mit „Stop tracking“ aus dem Git-Index entfernen, bleiben aber auf der Festplatte erhalten. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.",
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.", "Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Mit clone REMOTE ZIEL, --clone REMOTE ZIEL oder --clone=REMOTE ZIEL klont Gitty ein Remote-Repository in den exakt angegebenen lokalen Ordner und öffnet es anschließend. Relative Pfade werden gegen das aktuelle Arbeitsverzeichnis aufgelöst; der Aufruf wird auch an eine bereits laufende Gitty-Instanz weitergegeben.",
"Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.", "Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.",
], ],
note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.", note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.",
@@ -1636,6 +1652,20 @@
label: "What's new", label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.", description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [ sections: [
{
id: "changelog-2026-8-6",
title: "Version 2026.8.6",
summary: "This maintenance release stabilizes Git LFS workflows from tracking through clone and pull to pushing large files to Azure DevOps.",
steps: [
"LFS patterns from the root .gitattributes remain visible in the LFS dialog even while the file is untracked or was previously hidden by an ignore rule.",
"When Git LFS is activated or a tracking pattern is added, Gitty ensures that .gitattributes is not ignored. Only when required, the scoped !/.gitattributes exception is appended to .gitignore.",
"Clone and pull use the same remote and credentials to download LFS objects for detected LFS repositories. Fresh clones also activate the filters and pre-push hook automatically.",
"When Azure DevOps rejects a large LFS upload over HTTP/2 with HTTP 413, Gitty retries the push once with an HTTP/1.1 setting scoped to that command. Global and repository settings remain unchanged.",
"LFS, size, and other generic push failures are no longer mistaken for non-fast-forward rejections. The Pull before push and Push after pull flow is now offered only when the local branch is genuinely behind its remote.",
"The Tauri debug launcher removes only known non-routing test proxies from the Gitty child process. Real user and company proxies are preserved, keeping remote and LFS workflows testable in debug builds.",
],
note: "The HTTP/1.1 retry runs only after an LFS HTTP 413 failure. Changes to .gitattributes and .gitignore remain ordinary repository changes that must be committed and pushed.",
},
{ {
id: "changelog-2026-8-5", id: "changelog-2026-8-5",
title: "Version 2026.8.5", title: "Version 2026.8.5",
@@ -1643,11 +1673,11 @@
steps: [ steps: [
"Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.", "Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.",
"LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.", "LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.",
"After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.", "After a successful clone or pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. Fresh clones also activate LFS locally, so a second manual pull is no longer required.",
"Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.", "Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.",
"The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.", "The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.",
"The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. The selected name, parent path, and affected file count are now clearly separated.", "The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. In Changes and the File Explorer, new and untracked items can be added to .gitignore as an exact file, a complete folder, or an extension-wide pattern; the folder option only appears for folder selections. Tracked files and folders can be removed from the Git index with Stop tracking while remaining on disk. The selected name, parent path, and affected file count are now clearly separated.",
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.", "Repositories can be opened directly at startup with --repo PATH or --repo=PATH. With clone REMOTE TARGET, --clone REMOTE TARGET, or --clone=REMOTE TARGET, Gitty clones a remote into the exact local folder and opens it afterward. Relative paths are resolved against the current working directory, and requests are forwarded to an already-running Gitty instance.",
"Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.", "Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.",
], ],
note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.", note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.",
+1 -1
View File
@@ -277,7 +277,7 @@
<span class="eyebrow">{scopeLabel}</span> <span class="eyebrow">{scopeLabel}</span>
<p class="dialog-title" title={displayPath}>{displayPath}</p> <p class="dialog-title" title={displayPath}>{displayPath}</p>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions line-patch-header-actions">
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}> <div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
<span>{isGerman ? "Öffnen mit" : "Open with"}</span> <span>{isGerman ? "Öffnen mit" : "Open with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}> <button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
+104 -2
View File
@@ -4,13 +4,18 @@
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
FileDiff, FileDiff,
FileMinus2,
FileType,
FileX,
Folder, Folder,
FolderOpen, FolderOpen,
FolderMinus,
FolderTree, FolderTree,
FolderX,
RotateCcw, RotateCcw,
} from "@lucide/svelte"; } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png"; import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types"; import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
interface Props { interface Props {
changedFiles: GitFileStatus[]; changedFiles: GitFileStatus[];
@@ -27,6 +32,8 @@
onDiscard: (files: GitFileStatus[], staged: boolean) => void; onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void; onDiscardMany: (files: GitFileStatus[]) => void;
onStash: (files: GitFileStatus[], label: string) => void; onStash: (files: GitFileStatus[], label: string) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void;
onPatch: (file: GitFileStatus, staged: boolean) => void; onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void; onStageAll: () => void;
onUnstageAll: () => void; onUnstageAll: () => void;
@@ -76,6 +83,8 @@
onDiscard = () => {}, onDiscard = () => {},
onDiscardMany = () => {}, onDiscardMany = () => {},
onStash = () => {}, onStash = () => {},
onIgnore = () => {},
onStopTracking = () => {},
onPatch = () => {}, onPatch = () => {},
onStageAll = () => {}, onStageAll = () => {},
onUnstageAll = () => {}, onUnstageAll = () => {},
@@ -188,6 +197,7 @@
let statusContextTarget = $state<StatusContextTarget | null>(null); let statusContextTarget = $state<StatusContextTarget | null>(null);
let statusContextMenuX = $state(0); let statusContextMenuX = $state(0);
let statusContextMenuY = $state(0); let statusContextMenuY = $state(0);
let statusContextMenuElement = $state<HTMLDivElement | null>(null);
function toggleStatusFolder(lane: StatusLaneKind, path: string) { function toggleStatusFolder(lane: StatusLaneKind, path: string) {
const next = new Set(collapsedStatusFolders); const next = new Set(collapsedStatusFolders);
@@ -220,6 +230,12 @@
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288)); statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174)); statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
statusContextTarget = { lane, kind, label, files }; statusContextTarget = { lane, kind, label, files };
requestAnimationFrame(() => {
if (!statusContextMenuElement) return;
const bounds = statusContextMenuElement.getBoundingClientRect();
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8));
});
} }
function statusContextName(label: string): string { function statusContextName(label: string): string {
@@ -252,6 +268,45 @@
onStash(target.files, target.label); onStash(target.files, target.label);
} }
function isIgnoreableNewFile(file: GitFileStatus): boolean {
const path = file.path.replace(/\\/g, "/").toLowerCase();
if (path === ".gitignore") return false;
return file.unstaged === "untracked" || (file.staged === "added" && file.old_path === null);
}
function isTrackedStatusFile(file: GitFileStatus): boolean {
return file.unstaged !== "untracked" && file.staged !== "deleted";
}
function statusContextExtension(label: string): string {
const name = statusContextName(label);
const separator = name.lastIndexOf(".");
return separator > 0 && separator < name.length - 1 ? name.slice(separator + 1) : "";
}
function statusContextFolder(target: StatusContextTarget): string {
if (target.kind === "folder") return target.label.replace(/\\/g, "/").replace(/\/+$/, "");
const normalized = target.label.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "";
}
function runStatusContextIgnoreAction(kind: GitIgnoreKind) {
const target = statusContextTarget;
if (!target) return;
const ignoreTarget = kind === "folder" ? statusContextFolder(target) : target.label;
if (!ignoreTarget) return;
closeStatusContextMenu();
onIgnore(ignoreTarget, kind);
}
function runStatusContextStopTracking() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStopTracking(target.label, target.kind);
}
function handleStatusWindowKeydown(event: KeyboardEvent) { function handleStatusWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeStatusContextMenu(); if (event.key === "Escape") closeStatusContextMenu();
} }
@@ -340,6 +395,10 @@
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles)); let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length); let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length); let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false);
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
$effect(() => { $effect(() => {
const validKeys = new Set(changedFiles.map(fileKey)); const validKeys = new Set(changedFiles.map(fileKey));
@@ -517,7 +576,7 @@
</section> </section>
{#if statusContextTarget} {#if statusContextTarget}
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}> <div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div class="status-context-label"> <div class="status-context-label">
<span class="status-context-object-icon" aria-hidden="true"> <span class="status-context-object-icon" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if} {#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
@@ -547,6 +606,49 @@
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span> <span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
</span> </span>
</button> </button>
{#if statusContextCanIgnore || statusContextCanStopTracking}
<div class="menu-separator" role="separator"></div>
{/if}
{#if statusContextCanStopTracking}
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
<span class="status-context-action-icon untrack" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>Stop tracking {statusContextTarget.kind}</strong>
<span>Keep it on disk and remove it from Git</span>
</span>
</button>
{/if}
{#if statusContextCanIgnore}
{#if statusContextTarget.kind === "file"}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore file</strong>
<span>Add only this file to .gitignore</span>
</span>
</button>
{/if}
{#if statusContextIgnoreExtension}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
<span>Match this file type repository-wide</span>
</span>
</button>
{/if}
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore folder</strong>
<span>Add this folder and its contents to .gitignore</span>
</span>
</button>
{/if}
{/if}
</div> </div>
{/if} {/if}
+9
View File
@@ -14,6 +14,7 @@ import type {
GitBranch, GitBranch,
GitCommit, GitCommit,
GitCommitComparison, GitCommitComparison,
GitIgnoreKind,
GitLfsStatus, GitLfsStatus,
GitRepositoryFile, GitRepositoryFile,
GitRemote, GitRemote,
@@ -291,6 +292,14 @@ export function unstageFiles(path: string, files: string[]): Promise<GitStatus>
return invoke<GitStatus>("unstage_files", { path, files }); return invoke<GitStatus>("unstage_files", { path, files });
} }
export function addToGitignore(path: string, target: string, kind: GitIgnoreKind): Promise<GitStatus> {
return invoke<GitStatus>("add_to_gitignore", { path, target, kind });
}
export function untrackPaths(path: string, targets: string[]): Promise<GitStatus> {
return invoke<GitStatus>("untrack_paths", { path, targets });
}
export function restoreFiles( export function restoreFiles(
path: string, path: string,
files: string[], files: string[],
+3
View File
@@ -7,6 +7,8 @@ export type FileStatusKind =
| "conflicted" | "conflicted"
| "unknown"; | "unknown";
export type GitIgnoreKind = "file" | "extension" | "folder";
export type CommitAiPhase = "idle" | "loading" | "ready" | "error"; export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom"; export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed"; export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
@@ -228,6 +230,7 @@ export interface RepositoryBundle {
stashes: GitStash[]; stashes: GitStash[];
commits: GitCommit[]; commits: GitCommit[];
files: GitRepositoryFile[]; files: GitRepositoryFile[];
warning: string | null;
} }
export interface GitDiffFile { export interface GitDiffFile {