feat: update git commands and improve diff loading with timeout handling

This commit is contained in:
Bohdan Triapitsyn
2025-12-25 16:55:37 +02:00
parent 1c91e1810c
commit 508d0d1b4d
5 changed files with 463 additions and 69 deletions
+317 -40
View File
@@ -7,13 +7,22 @@ use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::process::Stdio;
use std::sync::LazyLock;
use tauri::State;
use tokio::fs;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
const GIT_IDENTITY_STORAGE_FILE: &str = "git-identities.json";
const GIT_FILE_DIFF_TIMEOUT_MS: u64 = 15_000;
const GIT_LS_REMOTE_TIMEOUT_MS: u64 = 5_000;
const GIT_FILE_TEXT_MAX_BYTES: u64 = 2_000_000;
const GIT_FILE_IMAGE_MAX_BYTES: u64 = 10_000_000;
// Tauri invoke payloads can become unstable with very large strings (e.g. huge blobs or base64 data URLs).
// Keep a conservative upper bound to ensure the diff IPC response always returns.
const GIT_FILE_IPC_MAX_CHARS: usize = 600_000;
// --- Structs mirroring TypeScript types ---
@@ -283,7 +292,11 @@ async fn run_git_with_allowed_exit(
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.kill_on_drop(true)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "Never")
.env("LC_ALL", "C")
.output()
.await
@@ -302,6 +315,177 @@ async fn run_git_with_allowed_exit(
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn run_git_bytes_with_allowed_exit_timeout(
args: &[&str],
cwd: &Path,
allowed_codes: &[i32],
timeout_ms: u64,
) -> Result<Vec<u8>> {
let output = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "Never")
.env("LC_ALL", "C")
.stdin(Stdio::null())
.kill_on_drop(true)
.output(),
)
.await
.map_err(|_| anyhow!("Git command timed out after {}ms", timeout_ms))?
.context("Failed to execute git command")?;
if !output.status.success() {
if let Some(code) = output.status.code() {
if allowed_codes.contains(&code) {
return Ok(output.stdout);
}
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("{}", stderr));
}
Ok(output.stdout)
}
async fn read_file_bytes_limited(path: &Path, max_bytes: u64) -> Result<(Vec<u8>, bool)> {
let file = tokio::fs::File::open(path).await?;
let mut buf = Vec::new();
let mut limited = file.take(max_bytes);
limited.read_to_end(&mut buf).await?;
let truncated = match tokio::fs::metadata(path).await {
Ok(meta) => meta.len() > max_bytes,
Err(_) => false,
};
Ok((buf, truncated))
}
async fn read_file_bytes_limited_with_timeout(
path: &Path,
max_bytes: u64,
timeout_ms: u64,
) -> Result<(Vec<u8>, bool)> {
tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
read_file_bytes_limited(path, max_bytes),
)
.await
.map_err(|_| anyhow!("File read timed out after {}ms", timeout_ms))?
}
async fn metadata_with_timeout(path: &Path, timeout_ms: u64) -> Result<std::fs::Metadata> {
tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
fs::metadata(path),
)
.await
.map_err(|_| anyhow!("Metadata read timed out after {}ms", timeout_ms))?
.map_err(|e| e.into())
}
fn normalize_relative_path(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
result.pop();
}
Component::CurDir => {}
Component::Normal(part) => result.push(part),
_ => {}
}
}
result
}
async fn resolve_repo_root(root: &Path) -> PathBuf {
match run_git(&["rev-parse", "--show-toplevel"], root).await {
Ok(output) => {
let trimmed = output.trim();
if trimmed.is_empty() {
root.to_path_buf()
} else {
PathBuf::from(trimmed)
}
}
Err(_) => root.to_path_buf(),
}
}
async fn resolve_git_paths(root: &Path, path_str: &str) -> (PathBuf, PathBuf, String) {
let repo_root = resolve_repo_root(root).await;
let path_candidate = if path_str.contains(" -> ") {
path_str
.split(" -> ")
.last()
.unwrap_or(path_str)
.trim()
.to_string()
} else {
path_str.to_string()
};
let input_path = Path::new(&path_candidate);
let absolute_path = if input_path.is_absolute() {
input_path.to_path_buf()
} else {
let from_root = root.join(input_path);
if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS).await.is_ok() {
from_root
} else {
repo_root.join(input_path)
}
};
let relative_path = absolute_path
.strip_prefix(&repo_root)
.unwrap_or(input_path)
.to_path_buf();
let normalized_relative = normalize_relative_path(&relative_path);
let mut relative_str = normalized_relative.to_string_lossy().replace('\\', "/");
if relative_str.is_empty() || Path::new(&relative_str).is_absolute() {
relative_str = path_candidate;
}
(repo_root, absolute_path, relative_str)
}
async fn resolve_path_for_git_show(root: &Path, path_str: &str) -> (PathBuf, PathBuf, String) {
// Prefer asking git for the repo-root-relative path when possible.
// This avoids subtle worktree/subdir path prefix issues.
let path_candidate = if path_str.contains(" -> ") {
path_str
.split(" -> ")
.last()
.unwrap_or(path_str)
.trim()
.to_string()
} else {
path_str.to_string()
};
let ls_files = run_git(&["ls-files", "--full-name", "--", &path_candidate], root)
.await
.unwrap_or_default();
let resolved = ls_files.lines().next().unwrap_or("").trim();
let (repo_root, full_path, fallback_relative) = resolve_git_paths(root, &path_candidate).await;
if !resolved.is_empty() {
return (
repo_root,
full_path,
resolved.to_string().replace('\\', "/"),
);
}
(repo_root, full_path, fallback_relative)
}
fn append_git_option(args: &mut Vec<String>, value: &Value) {
match value {
Value::Null => {}
@@ -439,8 +623,10 @@ pub async fn check_is_git_repository(
let path = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let git_dir = path.join(".git");
Ok(git_dir.exists())
match run_git(&["rev-parse", "--is-inside-work-tree"], &path).await {
Ok(output) => Ok(output.trim().eq_ignore_ascii_case("true")),
Err(_) => Ok(false),
}
}
#[tauri::command]
@@ -466,8 +652,12 @@ pub async fn get_git_status(
let mut behind = 0;
let entries: Vec<&str> = status_output.split('\0').collect();
let mut i = 0usize;
while i < entries.len() {
let entry = entries[i];
i += 1;
for entry in entries {
if entry.is_empty() {
continue;
}
@@ -499,11 +689,26 @@ pub async fn get_git_status(
continue;
}
// File entries: XY PATH or R ORIG_PATH -> PATH
// File entries (porcelain v1, -z):
// - Normal: XY<space>path
// - Rename/Copy: XY<space>old_path<null>new_path
if entry.len() >= 4 {
let index_status = &entry[0..1];
let working_status = &entry[1..2];
let file_path = &entry[3..];
let mut file_path = &entry[3..];
// Handle rename/copy by consuming the next NUL-terminated token as the new path.
let is_rename_or_copy = index_status == "R"
|| working_status == "R"
|| index_status == "C"
|| working_status == "C";
if is_rename_or_copy && i < entries.len() {
let next_path = entries[i];
if !next_path.is_empty() {
file_path = next_path;
i += 1;
}
}
// Simple-git parsing logic approximation
files.push(GitStatusFile {
@@ -687,22 +892,40 @@ fn get_image_mime_type(path: &str) -> &'static str {
}
}
async fn run_git_binary(args: &[&str], cwd: &Path) -> Result<Vec<u8>> {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("LC_ALL", "C")
.output()
.await
.context("Failed to execute git command")?;
// For binary output, we accept exit code 0 or check for actual content
if output.status.success() || !output.stdout.is_empty() {
Ok(output.stdout)
} else {
Ok(Vec::new())
fn truncate_string_to_char_boundary(mut value: String, max_chars: usize, suffix: &str) -> String {
if value.len() <= max_chars {
return value;
}
let mut cut = max_chars.min(value.len());
while cut > 0 && !value.is_char_boundary(cut) {
cut -= 1;
}
value.truncate(cut);
value.push_str(suffix);
value
}
fn cap_ipc_payload(value: String) -> String {
if value.is_empty() {
return value;
}
// Truncating base64 data URLs would produce invalid images; drop instead.
if value.starts_with("data:") {
return if value.len() <= GIT_FILE_IPC_MAX_CHARS {
value
} else {
String::new()
};
}
truncate_string_to_char_boundary(value, GIT_FILE_IPC_MAX_CHARS, "\n…(truncated for desktop)\n")
}
async fn run_git_binary(args: &[&str], cwd: &Path) -> Result<Vec<u8>> {
run_git_bytes_with_allowed_exit_timeout(args, cwd, &[0, 128], GIT_FILE_DIFF_TIMEOUT_MS).await
}
#[tauri::command]
@@ -718,39 +941,85 @@ pub async fn get_git_file_diff(
.await
.map_err(|e| e.to_string())?;
let is_image = is_image_file(&path_str);
let mime_type = if is_image { get_image_mime_type(&path_str) } else { "" };
let (repo_root, full_path, relative_path) = resolve_path_for_git_show(&root, &path_str).await;
let is_image = is_image_file(&relative_path);
let mime_type = if is_image { get_image_mime_type(&relative_path) } else { "" };
// Original from HEAD
let original = if is_image {
// For images, get binary content and convert to data URL
let original_spec = format!("HEAD:{}", path_str);
match run_git_binary(&["show", &original_spec], &root).await {
let original_spec = format!("HEAD:{}", relative_path);
match run_git_binary(&["show", &original_spec], &repo_root).await {
Ok(bytes) if !bytes.is_empty() => {
format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes))
if bytes.len() as u64 > GIT_FILE_IMAGE_MAX_BYTES {
String::new()
} else {
format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes))
}
}
_ => String::new(),
}
} else {
let original_spec = format!("HEAD:{}", path_str);
let original_args = vec!["show", original_spec.as_str()];
run_git_with_allowed_exit(&original_args, &root, &[0, 128])
.await
.unwrap_or_default()
let original_spec = format!("HEAD:{}", relative_path);
match run_git_bytes_with_allowed_exit_timeout(
&["show", original_spec.as_str()],
&repo_root,
&[0, 128],
GIT_FILE_DIFF_TIMEOUT_MS,
)
.await
{
Ok(bytes) if !bytes.is_empty() => {
if bytes.len() as u64 > GIT_FILE_TEXT_MAX_BYTES {
let mut text = String::from_utf8_lossy(
&bytes[..(GIT_FILE_TEXT_MAX_BYTES as usize).min(bytes.len())],
)
.to_string();
text.push_str("\n…(truncated)\n");
text
} else {
String::from_utf8_lossy(&bytes).to_string()
}
}
_ => String::new(),
}
};
// Modified from working tree (if file exists)
let full_path = root.join(&path_str);
let modified = if let Ok(metadata) = fs::metadata(&full_path).await {
let modified = if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await {
if metadata.is_file() {
if is_image {
// For images, read as binary and convert to data URL
match fs::read(&full_path).await {
Ok(bytes) => format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)),
Err(_) => String::new(),
if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES {
String::new()
} else {
match tokio::time::timeout(
std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS),
fs::read(&full_path),
)
.await
{
Ok(Ok(bytes)) => format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)),
_ => String::new(),
}
}
} else {
fs::read_to_string(&full_path).await.unwrap_or_default()
match read_file_bytes_limited_with_timeout(
&full_path,
GIT_FILE_TEXT_MAX_BYTES,
GIT_FILE_DIFF_TIMEOUT_MS,
)
.await
{
Ok((bytes, truncated)) => {
let mut text = String::from_utf8_lossy(&bytes).to_string();
if truncated {
text.push_str("\n…(truncated)\n");
}
text
}
Err(_) => String::new(),
}
}
} else {
String::new()
@@ -759,7 +1028,7 @@ pub async fn get_git_file_diff(
String::new()
};
Ok((original, modified))
Ok((cap_ipc_payload(original), cap_ipc_payload(modified)))
}
#[tauri::command]
@@ -826,8 +1095,16 @@ pub async fn get_git_branches(
.map_err(|e| e.to_string())?;
// Discover actual remote heads so we can drop stale remote-tracking refs
let allowed_remote_heads: Option<HashSet<String>> = match run_git(&["ls-remote", "--heads", "origin"], &root).await {
Ok(ls_remote) => {
let allowed_remote_heads: Option<HashSet<String>> = match run_git_bytes_with_allowed_exit_timeout(
&["ls-remote", "--heads", "origin"],
&root,
&[0],
GIT_LS_REMOTE_TIMEOUT_MS,
)
.await
{
Ok(bytes) => {
let ls_remote = String::from_utf8_lossy(&bytes);
let mut set = HashSet::new();
for line in ls_remote.lines() {
if let Some((_, ref_name)) = line.split_once('\t') {