Files
openchamber/packages/desktop/src-tauri/src/commands/git.rs
T
shekohex 0e35e7f46b fix(git): handle launchd SSH agent socket (#275)
* fix(git): propagate SSH_AUTH_SOCK to git commands

* fix(git): ignore launchd SSH_AUTH_SOCK

* fix(git): fallback to launchd SSH_AUTH_SOCK
2026-02-03 19:32:09 +02:00

2620 lines
82 KiB
Rust

use crate::path_utils::expand_tilde_path;
use crate::{DesktopRuntime, SettingsStore};
use anyhow::{anyhow, Context, Result};
use log::{error, info, warn};
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
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;
fn extract_json_object(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let mut start = match trimmed.find('{') {
Some(index) => index,
None => return None,
};
while start < trimmed.len() {
let mut end = match trimmed[start..].find('}') {
Some(index) => start + index,
None => break,
};
loop {
let candidate = &trimmed[start..=end];
if serde_json::from_str::<Value>(candidate).is_ok() {
return Some(candidate.to_string());
}
end = match trimmed[end + 1..].find('}') {
Some(index) => end + 1 + index,
None => break,
};
}
start = match trimmed[start + 1..].find('{') {
Some(index) => start + 1 + index,
None => break,
};
}
None
}
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 ---
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitStatusFile {
pub path: String,
pub index: String,
pub working_dir: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitStatus {
pub current: String,
pub tracking: Option<String>,
pub ahead: i32,
pub behind: i32,
pub files: Vec<GitStatusFile>,
pub is_clean: bool,
pub diff_stats: Option<HashMap<String, DiffStat>>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DiffStat {
pub insertions: i32,
pub deletions: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitBranchDetails {
pub current: bool,
pub name: String,
pub commit: String,
pub label: String,
pub tracking: Option<String>,
pub ahead: Option<i32>,
pub behind: Option<i32>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitBranch {
pub all: Vec<String>,
pub current: String,
pub branches: HashMap<String, GitBranchDetails>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitCommitSummary {
pub changes: i32,
pub insertions: i32,
pub deletions: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitCommitResult {
pub success: bool,
pub commit: String,
pub branch: String,
pub summary: GitCommitSummary,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitPushResult {
pub success: bool,
pub pushed: Vec<GitPushRef>,
pub repo: String,
#[serde(rename = "ref")]
pub ref_: Option<String>, // "ref" is a keyword in Rust
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitPushRef {
pub local: String,
pub remote: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitPullResult {
pub success: bool,
pub summary: GitCommitSummary,
pub files: Vec<String>,
pub insertions: i32,
pub deletions: i32,
}
fn parse_shortstat(output: &str) -> GitCommitSummary {
let mut summary = GitCommitSummary {
changes: 0,
insertions: 0,
deletions: 0,
};
for line in output
.split('\n')
.map(|line| line.trim())
.filter(|line| !line.is_empty())
{
for part in line.split(',') {
let token = part.trim();
if token.is_empty() {
continue;
}
if token.contains("file changed") {
if let Some(value) = token.split_whitespace().next() {
summary.changes = value.parse().unwrap_or(0);
}
} else if token.contains("insertion") {
if let Some(value) = token.split_whitespace().next() {
summary.insertions = value.parse().unwrap_or(0);
}
} else if token.contains("deletion") {
if let Some(value) = token.split_whitespace().next() {
summary.deletions = value.parse().unwrap_or(0);
}
}
}
}
summary
}
async fn get_head_hash(root: &Path) -> Result<String> {
let output = run_git(&["rev-parse", "HEAD"], root).await?;
Ok(output.trim().to_string())
}
async fn get_current_branch_name(root: &Path) -> Result<String> {
let output = run_git(&["rev-parse", "--abbrev-ref", "HEAD"], root).await?;
Ok(output.trim().to_string())
}
async fn collect_shortstat_for_range(root: &Path, range: &str) -> Result<GitCommitSummary> {
let args = ["diff", "--shortstat", range];
let output = run_git(&args, root).await.unwrap_or_default();
Ok(parse_shortstat(&output))
}
async fn collect_changed_files_for_range(root: &Path, range: &str) -> Result<Vec<String>> {
let args = ["diff", "--name-only", range];
let output = run_git(&args, root).await.unwrap_or_default();
Ok(output
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitIdentityProfile {
pub id: String,
pub name: String,
pub user_name: String,
pub user_email: String,
pub auth_type: Option<String>,
pub ssh_key: Option<String>,
pub host: Option<String>,
pub color: Option<String>,
pub icon: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredGitCredential {
pub host: String,
pub username: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitIdentityProfilesWrapper {
pub profiles: Vec<GitIdentityProfile>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitIdentitySummary {
pub user_name: Option<String>,
pub user_email: Option<String>,
pub ssh_command: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitLogEntry {
pub hash: String,
pub date: String,
pub message: String,
pub refs: String,
pub body: String,
#[serde(rename = "author_name")]
pub author_name: String,
#[serde(rename = "author_email")]
pub author_email: String,
pub files_changed: i32,
pub insertions: i32,
pub deletions: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitLogResponse {
pub all: Vec<GitLogEntry>,
pub latest: Option<GitLogEntry>,
pub total: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitWorktreeInfo {
pub worktree: String,
pub head: Option<String>,
pub branch: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GeneratedCommitMessage {
pub subject: String,
pub highlights: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CommitFileEntry {
pub path: String,
pub insertions: i32,
pub deletions: i32,
pub is_binary: bool,
pub change_type: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GitCommitFilesResponse {
pub files: Vec<CommitFileEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CommitMessageResponse {
pub message: GeneratedCommitMessage,
}
// --- Constants & Regexes ---
static WORKTREE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^worktree (.+)$").unwrap());
static HEAD_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^HEAD (.+)$").unwrap());
static BRANCH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^branch (.+)$").unwrap());
static FILES_CHANGED_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\d+)\s+files?\s+changed").unwrap());
static INSERTIONS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\d+)\s+insertions?\(\+\)").unwrap());
static DELETIONS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\d+)\s+deletions?\(-\)").unwrap());
// --- Helpers ---
fn metadata_is_socket(metadata: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
return metadata.file_type().is_socket();
}
#[cfg(not(unix))]
{
false
}
}
fn is_launchd_listeners_socket(path: &str) -> bool {
path.contains("/com.apple.launchd.") && path.ends_with("/Listeners")
}
async fn run_gpgconf(args: &[&str]) -> Option<Vec<u8>> {
let candidates = [
"gpgconf",
"/opt/homebrew/bin/gpgconf",
"/usr/local/bin/gpgconf",
];
for candidate in candidates {
info!("git: trying gpgconf at {}", candidate);
let output = Command::new(candidate)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await;
if let Ok(result) = output {
if result.status.success() {
info!("git: gpgconf succeeded at {}", candidate);
return Some(result.stdout);
}
}
}
None
}
async fn resolve_ssh_auth_sock() -> Option<String> {
let mut launchd_fallback: Option<String> = None;
if let Ok(value) = std::env::var("SSH_AUTH_SOCK") {
let trimmed = value.trim();
if !trimmed.is_empty() {
if let Ok(metadata) = fs::metadata(trimmed).await {
if metadata_is_socket(&metadata) {
if is_launchd_listeners_socket(trimmed) {
info!(
"git: SSH_AUTH_SOCK points to launchd listeners: {}",
trimmed
);
launchd_fallback = Some(trimmed.to_string());
} else {
info!("git: using SSH_AUTH_SOCK from environment: {}", trimmed);
return Some(trimmed.to_string());
}
}
}
}
}
let home_dir = dirs::home_dir()?;
let gpg_agent_sock = home_dir.join(".gnupg").join("S.gpg-agent.ssh");
if let Ok(metadata) = fs::metadata(&gpg_agent_sock).await {
if metadata_is_socket(&metadata) {
info!(
"git: using gpg-agent SSH socket: {}",
gpg_agent_sock.to_string_lossy()
);
return Some(gpg_agent_sock.to_string_lossy().to_string());
}
}
let mut gpgconf_path: Option<PathBuf> = None;
if let Some(stdout) = run_gpgconf(&["--list-dirs", "agent-ssh-socket"]).await {
let candidate = String::from_utf8_lossy(&stdout).trim().to_string();
if !candidate.is_empty() {
let path = PathBuf::from(candidate);
info!("git: gpgconf reported SSH socket at {}", path.to_string_lossy());
if let Ok(metadata) = fs::metadata(&path).await {
if metadata_is_socket(&metadata) {
info!("git: gpgconf socket exists and is a socket");
return Some(path.to_string_lossy().to_string());
}
}
gpgconf_path = Some(path);
}
}
if gpgconf_path.is_some() {
info!("git: launching gpg-agent via gpgconf");
let _ = run_gpgconf(&["--launch", "gpg-agent"]).await;
if let Some(stdout) = run_gpgconf(&["--list-dirs", "agent-ssh-socket"]).await {
let candidate = String::from_utf8_lossy(&stdout).trim().to_string();
if !candidate.is_empty() {
let path = PathBuf::from(candidate);
info!("git: gpgconf retried SSH socket at {}", path.to_string_lossy());
if let Ok(metadata) = fs::metadata(&path).await {
if metadata_is_socket(&metadata) {
info!("git: gpgconf socket exists and is a socket after launch");
return Some(path.to_string_lossy().to_string());
}
}
}
}
}
if let Some(fallback) = launchd_fallback {
info!("git: falling back to launchd SSH_AUTH_SOCK: {}", fallback);
return Some(fallback);
}
warn!("git: no SSH_AUTH_SOCK resolved");
None
}
async fn run_git(args: &[&str], cwd: &Path) -> Result<String> {
run_git_with_allowed_exit(args, cwd, &[]).await
}
async fn run_git_with_allowed_exit(
args: &[&str],
cwd: &Path,
allowed_codes: &[i32],
) -> Result<String> {
info!("git: running command {:?}", args);
let ssh_auth_sock = resolve_ssh_auth_sock().await;
let mut command = Command::new("git");
command
.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");
if let Some(sock) = ssh_auth_sock.as_deref() {
info!("git: setting SSH_AUTH_SOCK for command: {}", sock);
command.env("SSH_AUTH_SOCK", sock);
}
let output = command
.output()
.await
.context("Failed to execute git command")?;
if !output.status.success() {
if let Some(code) = output.status.code() {
if allowed_codes.contains(&code) {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("{}", stderr));
}
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>> {
info!("git: running command {:?}", args);
let ssh_auth_sock = resolve_ssh_auth_sock().await;
let mut command = Command::new("git");
command
.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);
if let Some(sock) = ssh_auth_sock.as_deref() {
info!("git: setting SSH_AUTH_SOCK for command: {}", sock);
command.env("SSH_AUTH_SOCK", sock);
}
let output = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
command.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 => {}
Value::Bool(false) => {}
Value::Bool(true) => {}
Value::Number(num) => args.push(num.to_string()),
Value::String(text) => {
let trimmed = text.trim();
if !trimmed.is_empty() {
args.push(trimmed.to_string());
}
}
Value::Array(items) => {
for item in items {
append_git_option(args, item);
}
}
Value::Object(map) => append_git_option_map(args, map),
}
}
fn append_git_option_map(args: &mut Vec<String>, map: &serde_json::Map<String, Value>) {
for (key, value) in map {
let flag = key.trim();
if flag.is_empty() {
continue;
}
match value {
Value::Null | Value::Bool(true) => args.push(flag.to_string()),
Value::Bool(false) => {}
Value::String(text) => args.push(format!("{flag}={text}")),
Value::Number(num) => args.push(format!("{flag}={num}")),
Value::Array(items) => {
if items.is_empty() {
args.push(flag.to_string());
} else {
for item in items {
match item {
Value::Null | Value::Bool(true) => args.push(flag.to_string()),
Value::Bool(false) => {}
Value::String(text) => args.push(format!("{flag}={text}")),
Value::Number(num) => args.push(format!("{flag}={num}")),
other => append_git_option(args, other),
}
}
}
}
other => args.push(format!("{flag}={other}")),
}
}
}
// Removed unused resolve_workspace_root function
async fn validate_git_path(path: &str, _settings: &SettingsStore) -> Result<PathBuf> {
let path_buf = expand_tilde_path(path);
if !path_buf.exists() {
return Err(anyhow!("Directory does not exist: {}", path));
}
if !path_buf.is_absolute() {
return Err(anyhow!("Path must be absolute"));
}
Ok(path_buf)
}
// --- Identity Storage ---
async fn get_identity_storage_path() -> Result<PathBuf> {
let mut path = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?;
path.push(".config");
path.push("openchamber");
fs::create_dir_all(&path).await?;
path.push(GIT_IDENTITY_STORAGE_FILE);
Ok(path)
}
async fn load_identities() -> Result<Vec<GitIdentityProfile>> {
let path = get_identity_storage_path().await?;
info!("Loading identities from {:?}", path);
if !path.exists() {
info!("Identities file does not exist at {:?}", path);
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).await?;
info!("Read {} bytes from identities file", content.len());
let wrapper: serde_json::Value = match serde_json::from_str(&content) {
Ok(w) => w,
Err(e) => {
error!("Failed to parse identities JSON: {}", e);
return Err(e.into());
}
};
// Handle both array and object wrapper format if needed, but spec says object with profiles array
if let Some(profiles) = wrapper.get("profiles") {
match serde_json::from_value::<Vec<GitIdentityProfile>>(profiles.clone()) {
Ok(p) => {
info!("Successfully loaded {} profiles", p.len());
Ok(p)
}
Err(e) => {
error!("Failed to deserialize profiles array: {}", e);
// Log the failing JSON segment for debugging
warn!("Profiles JSON: {}", profiles);
Err(e.into())
}
}
} else {
warn!("No 'profiles' key found in identities JSON");
Ok(Vec::new())
}
}
async fn save_identities(profiles: Vec<GitIdentityProfile>) -> Result<()> {
let path = get_identity_storage_path().await?;
let wrapper = GitIdentityProfilesWrapper { profiles };
let content = serde_json::to_string_pretty(&wrapper)?;
fs::write(path, content).await?;
Ok(())
}
// --- Commands ---
#[tauri::command]
pub async fn check_is_git_repository(
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<bool, String> {
let path = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
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]
pub async fn get_git_status(
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<GitStatus, String> {
let path = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
// 1. Get porcelain status
// Use -uall to show all untracked files individually, not just directories
let status_output = run_git(&["status", "--porcelain", "-b", "-z", "-uall"], &path)
.await
.map_err(|e| e.to_string())?;
// Parse status output
let mut files = Vec::new();
let mut current = String::new();
let mut tracking = None;
let mut ahead = 0;
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;
if entry.is_empty() {
continue;
}
if entry.starts_with("## ") {
// Branch info: ## main...origin/main [ahead 1, behind 2]
let branch_line = &entry[3..];
if let Some((local, remote_part)) = branch_line.split_once("...") {
current = local.to_string();
// Parse remote part for ahead/behind
// Format: origin/main [ahead 1, behind 2] or origin/main
if let Some(bracket_start) = remote_part.find('[') {
tracking = Some(remote_part[..bracket_start].trim().to_string());
let stats = &remote_part[bracket_start + 1..remote_part.len() - 1]; // inside brackets
for part in stats.split(", ") {
if let Some(val) = part.strip_prefix("ahead ") {
ahead = val.parse().unwrap_or(0);
} else if let Some(val) = part.strip_prefix("behind ") {
behind = val.parse().unwrap_or(0);
}
}
} else {
tracking = Some(remote_part.trim().to_string());
}
} else {
// No remote or initial commit
current = branch_line.to_string();
}
continue;
}
// 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 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 {
path: file_path.to_string(),
index: index_status.trim().to_string(),
working_dir: working_status.trim().to_string(),
});
}
}
// 2. Get diff stats (staged and unstaged)
let mut diff_stats = HashMap::new();
let collect_stats = |output: String| {
let mut stats = HashMap::new();
for line in output.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let insertions = if parts[0] == "-" {
0
} else {
parts[0].parse().unwrap_or(0)
};
let deletions = if parts[1] == "-" {
0
} else {
parts[1].parse().unwrap_or(0)
};
let path = parts[2].to_string();
stats.insert(
path,
DiffStat {
insertions,
deletions,
},
);
}
}
stats
};
let staged_stats_raw = run_git(&["diff", "--cached", "--numstat"], &path)
.await
.unwrap_or_default();
let working_stats_raw = run_git(&["diff", "--numstat"], &path)
.await
.unwrap_or_default();
let staged_stats = collect_stats(staged_stats_raw);
let working_stats = collect_stats(working_stats_raw);
// Merge stats
let mut all_paths: HashSet<String> = staged_stats.keys().cloned().collect();
all_paths.extend(working_stats.keys().cloned());
for p in all_paths {
let s = staged_stats.get(&p).unwrap_or(&DiffStat {
insertions: 0,
deletions: 0,
});
let w = working_stats.get(&p).unwrap_or(&DiffStat {
insertions: 0,
deletions: 0,
});
diff_stats.insert(
p,
DiffStat {
insertions: s.insertions + w.insertions,
deletions: s.deletions + w.deletions,
},
);
}
// 3. Handle new/untracked files (manual calculation if needed, or skip if complex)
// Node implementation does manual read. For now, let's assume files with '??' or 'A'
// might need stats if they aren't in numstat.
// NOTE: untracked files don't show up in `git diff --numstat`.
// We can try `wc -l` logic but Rust fs read is safer.
for file in &files {
if (file.working_dir == "?" || file.index == "A") && !diff_stats.contains_key(&file.path) {
let full_path = path.join(&file.path);
if let Ok(metadata) = fs::metadata(&full_path).await {
if metadata.is_file() {
if let Ok(content) = fs::read_to_string(&full_path).await {
let lines = content.lines().count() as i32;
diff_stats.insert(
file.path.clone(),
DiffStat {
insertions: lines,
deletions: 0,
},
);
}
}
}
}
}
// When there's no upstream yet (e.g. a freshly-created local worktree branch),
// git status doesn't report ahead/behind. We still want to surface unpublished commits.
if tracking.is_none() && !current.trim().is_empty() {
let mut base_candidates: Vec<String> = Vec::new();
let origin_head = run_git_with_allowed_exit(
&["symbolic-ref", "-q", "refs/remotes/origin/HEAD"],
&path,
&[1],
)
.await
.unwrap_or_default();
if !origin_head.trim().is_empty() {
base_candidates.push(origin_head.trim().replace("refs/remotes/", ""));
}
base_candidates.push("origin/main".to_string());
base_candidates.push("origin/master".to_string());
base_candidates.push("main".to_string());
base_candidates.push("master".to_string());
let mut selected_base: Option<String> = None;
for candidate in base_candidates {
let verified =
run_git_with_allowed_exit(&["rev-parse", "--verify", &candidate], &path, &[1])
.await
.unwrap_or_default();
if !verified.trim().is_empty() {
selected_base = Some(candidate);
break;
}
}
if let Some(base_ref) = selected_base {
let range = format!("{}..HEAD", base_ref);
if let Ok(raw) = run_git(&["rev-list", "--count", &range], &path).await {
if let Ok(count) = raw.trim().parse::<i32>() {
ahead = count;
behind = 0;
}
}
}
}
Ok(GitStatus {
current,
tracking,
ahead,
behind,
is_clean: files.is_empty(),
files,
diff_stats: Some(diff_stats),
})
}
#[tauri::command]
pub async fn get_git_diff(
directory: String,
path_str: String,
staged: Option<bool>,
context_lines: Option<u32>,
state: State<'_, DesktopRuntime>,
) -> Result<String, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let mut args = vec!["diff", "--no-color"];
let context = format!("-U{}", context_lines.unwrap_or(3));
args.push(&context);
if staged.unwrap_or(false) {
args.push("--cached");
}
args.push("--");
args.push(&path_str);
let output = run_git(&args, &root).await.unwrap_or_default();
if output.trim().is_empty() && !staged.unwrap_or(false) {
// Try --no-index for untracked files
// git diff --no-index -- /dev/null path
let full_path = root.join(&path_str);
if full_path.exists() {
let args_no_index = vec![
"diff",
"--no-color",
&context,
"--no-index",
"--",
"/dev/null",
&path_str,
];
return run_git_with_allowed_exit(&args_no_index, &root, &[1])
.await
.map_err(|e| e.to_string());
}
}
Ok(output)
}
const IMAGE_EXTENSIONS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif",
];
fn is_image_file(path: &str) -> bool {
if let Some(ext) = path.rsplit('.').next() {
IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str())
} else {
false
}
}
fn get_image_mime_type(path: &str) -> &'static str {
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"ico" => "image/x-icon",
"bmp" => "image/bmp",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
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]
pub async fn get_git_file_diff(
directory: String,
path_str: String,
state: State<'_, DesktopRuntime>,
) -> Result<(String, String), String> {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use tokio::fs;
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
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:{}", relative_path);
match run_git_binary(&["show", &original_spec], &repo_root).await {
Ok(bytes) if !bytes.is_empty() => {
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:{}", 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 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
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 {
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()
}
} else {
String::new()
};
Ok((cap_ipc_payload(original), cap_ipc_payload(modified)))
}
#[tauri::command]
pub async fn revert_git_file(
directory: String,
file_path: String,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
// Check if tracked
let is_tracked = run_git(&["ls-files", "--error-unmatch", &file_path], &root)
.await
.is_ok();
if !is_tracked {
// Clean untracked
let _ = run_git(&["clean", "-f", "-d", "--", &file_path], &root).await;
// Fallback fs remove if git clean failed (e.g. ignored files)
let full_path = root.join(&file_path);
if full_path.exists() {
if full_path.is_dir() {
let _ = fs::remove_dir_all(full_path).await;
} else {
let _ = fs::remove_file(full_path).await;
}
}
} else {
// Restore staged
let _ = run_git(&["restore", "--staged", &file_path], &root).await;
// Restore working
let _ = run_git(&["restore", &file_path], &root).await;
}
Ok(())
}
#[tauri::command]
pub async fn is_linked_worktree(
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<bool, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let git_dir = run_git(&["rev-parse", "--git-dir"], &root)
.await
.unwrap_or_default();
let common_dir = run_git(&["rev-parse", "--git-common-dir"], &root)
.await
.unwrap_or_default();
Ok(git_dir.trim() != common_dir.trim())
}
#[tauri::command]
pub async fn get_git_branches(
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<GitBranch, String> {
let root = validate_git_path(&directory, state.settings())
.await
.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_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') {
if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") {
set.insert(stripped.to_string());
}
}
}
Some(set)
}
Err(err) => {
warn!("Failed to list remote heads: {}", err);
None
}
};
// Structured for-each-ref output so we can mark remotes consistently with the web runtime
let output = run_git(
&[
"for-each-ref",
"--format=%(refname)|%(refname:short)|%(objectname)|%(upstream:short)|%(HEAD)|%(upstream:track)",
"refs/heads",
"refs/remotes",
],
&root,
)
.await
.map_err(|e| e.to_string())?;
let mut all = Vec::new();
let mut current_branch = String::new();
let mut branches = HashMap::new();
for line in output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() < 6 {
continue;
}
let full_ref = parts[0].trim();
let short_name = parts[1].trim();
let commit = parts[2].to_string();
let upstream = parts[3].trim();
let is_current = parts[4] == "*";
let track_info = parts[5];
let is_remote = full_ref.starts_with("refs/remotes/");
let normalized_name = if is_remote {
let (remote_name, branch_name) = match short_name.split_once('/') {
Some(parts) => parts,
None => continue, // skip malformed remote ref without branch
};
if branch_name == "HEAD" {
continue;
}
if let Some(allowed) = &allowed_remote_heads {
if !allowed.contains(branch_name) {
continue;
}
}
format!("remotes/{}/{}", remote_name, branch_name)
} else {
short_name.to_string()
};
let tracking = if upstream.is_empty() {
None
} else {
Some(upstream.to_string())
};
if is_current {
current_branch = normalized_name.clone();
}
all.push(normalized_name.clone());
let mut ahead = None;
let mut behind = None;
// Parse track info like "[ahead 1, behind 2]"
if !track_info.is_empty() {
let content = track_info.trim_matches(|c| c == '[' || c == ']');
for part in content.split(", ") {
if let Some(val) = part.strip_prefix("ahead ") {
ahead = val.parse().ok();
} else if let Some(val) = part.strip_prefix("behind ") {
behind = val.parse().ok();
}
}
}
branches.insert(
normalized_name.clone(),
GitBranchDetails {
current: is_current,
name: normalized_name,
commit,
label: short_name.to_string(),
tracking,
ahead,
behind,
},
);
}
Ok(GitBranch {
all,
current: current_branch,
branches,
})
}
#[tauri::command]
pub async fn delete_git_branch(
directory: String,
branch: String,
force: Option<bool>,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let flag = if force.unwrap_or(false) { "-D" } else { "-d" };
run_git(&["branch", flag, &branch], &root)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn delete_remote_branch(
directory: String,
branch: String,
remote: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let remote_name = remote.unwrap_or_else(|| "origin".to_string());
// branch might be refs/heads/foo or just foo
let clean_branch = branch.trim_start_matches("refs/heads/");
run_git(&["push", &remote_name, "--delete", clean_branch], &root)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_git_worktrees(
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<Vec<GitWorktreeInfo>, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let output = run_git(&["worktree", "list", "--porcelain"], &root)
.await
.map_err(|e| e.to_string())?;
let mut worktrees = Vec::new();
let mut current = GitWorktreeInfo {
worktree: String::new(),
head: None,
branch: None,
};
for line in output.lines() {
if let Some(cap) = WORKTREE_REGEX.captures(line) {
if !current.worktree.is_empty() {
worktrees.push(current.clone());
current = GitWorktreeInfo {
worktree: String::new(),
head: None,
branch: None,
};
}
current.worktree = cap[1].to_string();
} else if let Some(cap) = HEAD_REGEX.captures(line) {
current.head = Some(cap[1].to_string());
} else if let Some(cap) = BRANCH_REGEX.captures(line) {
current.branch = Some(cap[1].trim_start_matches("refs/heads/").to_string());
} else if line.is_empty() {
if !current.worktree.is_empty() {
worktrees.push(current.clone());
current = GitWorktreeInfo {
worktree: String::new(),
head: None,
branch: None,
};
}
}
}
if !current.worktree.is_empty() {
worktrees.push(current);
}
Ok(worktrees)
}
#[tauri::command]
pub async fn add_git_worktree(
directory: String,
path_str: String,
branch: String,
create_branch: Option<bool>,
start_point: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let mut args = vec!["worktree", "add"];
if create_branch.unwrap_or(false) {
args.push("-b");
args.push(&branch);
}
args.push(&path_str);
if !create_branch.unwrap_or(false) {
args.push(&branch);
} else if let Some(start_point) = start_point.as_deref() {
let start_point = start_point.trim();
if !start_point.is_empty() {
args.push(start_point);
}
}
run_git(&args, &root).await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn remove_git_worktree(
directory: String,
path_str: String,
force: Option<bool>,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let mut args = vec!["worktree", "remove", &path_str];
if force.unwrap_or(false) {
args.push("--force");
}
run_git(&args, &root).await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn ensure_openchamber_ignored(
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let exclude_path = root.join(".git/info/exclude");
if let Some(parent) = exclude_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| e.to_string())?;
}
let entry = "/.openchamber/\n";
let mut content = fs::read_to_string(&exclude_path).await.unwrap_or_default();
if !content.contains("/.openchamber/") {
if !content.ends_with('\n') && !content.is_empty() {
content.push('\n');
}
content.push_str(entry);
fs::write(&exclude_path, content)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn create_git_commit(
directory: String,
message: String,
add_all: Option<bool>,
files: Option<Vec<String>>,
state: State<'_, DesktopRuntime>,
) -> Result<GitCommitResult, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
if add_all.unwrap_or(false) {
run_git(&["add", "."], &root)
.await
.map_err(|e| e.to_string())?;
} else if let Some(file_list) = files {
if !file_list.is_empty() {
let mut args = vec!["add"];
args.extend(file_list.iter().map(|s| s.as_str()));
run_git(&args, &root).await.map_err(|e| e.to_string())?;
}
}
run_git(&["commit", "-m", &message], &root)
.await
.map_err(|e| e.to_string())?;
let commit_hash = get_head_hash(&root).await.map_err(|e| e.to_string())?;
let branch_name = get_current_branch_name(&root)
.await
.unwrap_or_else(|_| "HEAD".to_string());
let stat_output = run_git(&["log", "-1", "--pretty=", "--shortstat"], &root)
.await
.unwrap_or_default();
let summary = parse_shortstat(&stat_output);
Ok(GitCommitResult {
success: true,
commit: commit_hash,
branch: branch_name,
summary,
})
}
#[tauri::command]
pub async fn git_push(
directory: String,
remote: Option<String>,
branch: Option<String>,
options: Option<Value>,
state: State<'_, DesktopRuntime>,
) -> Result<GitPushResult, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let remote_name = remote.unwrap_or_else(|| "origin".to_string());
let explicit_branch = branch
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let mut branch_name = branch.unwrap_or_default();
let mut args = vec!["push".to_string(), remote_name.clone()];
if branch_name.is_empty() {
branch_name = get_current_branch_name(&root).await.unwrap_or_default();
}
if !branch_name.is_empty() {
// If caller didn't specify a branch and there's no upstream configured yet,
// publish on first push so future pushes/pulls work without extra prompts.
if !explicit_branch {
let remote_key = format!("branch.{}.remote", branch_name);
let merge_key = format!("branch.{}.merge", branch_name);
let upstream_remote =
run_git_with_allowed_exit(&["config", "--get", &remote_key], &root, &[1])
.await
.unwrap_or_default();
let upstream_merge =
run_git_with_allowed_exit(&["config", "--get", &merge_key], &root, &[1])
.await
.unwrap_or_default();
if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() {
args.push("--set-upstream".to_string());
}
}
args.push(branch_name.clone());
}
if let Some(extra) = options.as_ref() {
append_git_option(&mut args, extra);
}
let arg_refs: Vec<&str> = args.iter().map(|value| value.as_str()).collect();
// TODO: Streaming? Frontend types.ts defines GitPushResult, but doesn't mention streaming response for this call,
// but Stage 2 plan says "streaming progress events for long operations".
// Implementing simple await for now as `simple-git` wrapper does in `git-service.js`.
run_git(&arg_refs, &root).await.map_err(|e| e.to_string())?;
Ok(GitPushResult {
success: true,
pushed: if branch_name.is_empty() {
vec![]
} else {
vec![GitPushRef {
local: branch_name.clone(),
remote: format!("{}/{}", remote_name, branch_name),
}]
},
repo: remote_name,
ref_: if branch_name.is_empty() {
None
} else {
Some(branch_name)
},
})
}
#[tauri::command]
pub async fn git_pull(
directory: String,
remote: Option<String>,
branch: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<GitPullResult, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let r = remote.unwrap_or_else(|| "origin".to_string());
let b = branch.unwrap_or_default();
let mut args = vec!["pull", &r];
if !b.is_empty() {
args.push(&b);
}
let previous_head = get_head_hash(&root).await.ok();
run_git(&args, &root).await.map_err(|e| e.to_string())?;
let (summary, files) = if let Some(previous) = previous_head {
let new_head = get_head_hash(&root).await.unwrap_or(previous.clone());
if new_head != previous {
let range = format!("{previous}..{new_head}");
let summary = collect_shortstat_for_range(&root, &range)
.await
.unwrap_or_else(|_| GitCommitSummary {
changes: 0,
insertions: 0,
deletions: 0,
});
let files = collect_changed_files_for_range(&root, &range)
.await
.unwrap_or_default();
(summary, files)
} else {
(
GitCommitSummary {
changes: 0,
insertions: 0,
deletions: 0,
},
vec![],
)
}
} else {
(
GitCommitSummary {
changes: 0,
insertions: 0,
deletions: 0,
},
vec![],
)
};
Ok(GitPullResult {
success: true,
summary: summary.clone(),
files,
insertions: summary.insertions,
deletions: summary.deletions,
})
}
#[tauri::command]
pub async fn git_fetch(
directory: String,
remote: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let r = remote.unwrap_or_else(|| "origin".to_string());
run_git(&["fetch", &r], &root)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn checkout_branch(
directory: String,
branch: String,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
run_git(&["checkout", &branch], &root)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn create_branch(
directory: String,
name: String,
start_point: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let start = start_point.unwrap_or_else(|| "HEAD".to_string());
run_git(&["checkout", "-b", &name, &start], &root)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn rename_branch(
directory: String,
old_name: String,
new_name: String,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
run_git(&["branch", "-m", &old_name, &new_name], &root)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_git_log(
directory: String,
max_count: Option<i32>,
from: Option<String>,
to: Option<String>,
file: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<GitLogResponse, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let max = max_count.unwrap_or(50).to_string();
let mut args = vec![
"log",
"--max-count",
&max,
"--date=iso",
"--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e",
"--shortstat",
];
let range;
if let (Some(f), Some(t)) = (&from, &to) {
range = format!("{}..{}", f, t);
args.push(&range);
} else if let Some(f) = &from {
range = format!("{}..HEAD", f);
args.push(&range);
} else if let Some(t) = &to {
args.push(t);
}
if let Some(f) = &file {
args.push("--");
args.push(f);
}
let output = run_git(&args, &root).await.map_err(|e| e.to_string())?;
let mut entries = Vec::new();
let entries_raw: Vec<&str> = output.split('\x1e').collect();
let mut current_header = if !entries_raw.is_empty() {
entries_raw[0].trim()
} else {
""
};
for i in 1..entries_raw.len() {
let chunk = entries_raw[i];
if current_header.is_empty() {
break;
}
let header_parts: Vec<&str> = current_header.split('\x1f').collect();
if header_parts.len() < 5 {
// Try to recover next header anyway before skipping
// But current_header is invalid, so we can't push an entry.
// We still need to update current_header for the next loop.
} else {
let hash = header_parts[0];
let name = header_parts[1];
let email = header_parts[2];
let date = header_parts[3];
let subject = header_parts[4];
let mut files_changed = 0;
let mut insertions = 0;
let mut deletions = 0;
if let Some(cap) = FILES_CHANGED_REGEX.captures(chunk) {
files_changed = cap[1].parse().unwrap_or(0);
}
if let Some(cap) = INSERTIONS_REGEX.captures(chunk) {
insertions = cap[1].parse().unwrap_or(0);
}
if let Some(cap) = DELETIONS_REGEX.captures(chunk) {
deletions = cap[1].parse().unwrap_or(0);
}
entries.push(GitLogEntry {
hash: hash.to_string(),
author_name: name.to_string(),
author_email: email.to_string(),
date: date.to_string(),
message: subject.to_string(),
body: String::new(),
refs: String::new(),
files_changed,
insertions,
deletions,
});
}
// Find next header by looking for the line containing \x1f (separator used in format)
// The chunk contains stats then the next header.
current_header = "";
for line in chunk.lines().rev() {
let trimmed = line.trim();
if !trimmed.is_empty() && trimmed.contains('\x1f') {
current_header = trimmed;
break;
}
}
}
if entries.is_empty() && !output.is_empty() {
for line in output.lines() {
let parts: Vec<&str> = line.split('\x1f').collect();
if parts.len() >= 5 {
entries.push(GitLogEntry {
hash: parts[0].to_string(),
author_name: parts[1].to_string(),
author_email: parts[2].to_string(),
date: parts[3].to_string(),
message: parts[4].to_string(),
body: "".to_string(),
refs: "".to_string(),
files_changed: 0,
insertions: 0,
deletions: 0,
});
}
}
}
Ok(GitLogResponse {
all: entries.clone(),
latest: entries.first().cloned(),
total: entries.len() as i32,
})
}
#[tauri::command]
pub async fn get_commit_files(
directory: String,
hash: String,
state: State<'_, DesktopRuntime>,
) -> Result<GitCommitFilesResponse, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
// Get numstat for insertions/deletions per file
let numstat_output = run_git(&["show", "--numstat", "--format=", &hash], &root)
.await
.map_err(|e| e.to_string())?;
let mut files = Vec::new();
for line in numstat_output.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
continue;
}
let insertions_raw = parts[0];
let deletions_raw = parts[1];
let file_path = parts[2..].join("\t");
if file_path.is_empty() {
continue;
}
// Binary files show '-' for stats
let is_binary = insertions_raw == "-" && deletions_raw == "-";
let insertions = if insertions_raw == "-" {
0
} else {
insertions_raw.parse().unwrap_or(0)
};
let deletions = if deletions_raw == "-" {
0
} else {
deletions_raw.parse().unwrap_or(0)
};
files.push(CommitFileEntry {
path: file_path,
insertions,
deletions,
is_binary,
change_type: "M".to_string(), // Default, will update below
});
}
// Get accurate change types using --name-status
let name_status_output = run_git(&["show", "--name-status", "--format=", &hash], &root)
.await
.unwrap_or_default();
let mut status_map: HashMap<String, String> = HashMap::new();
for line in name_status_output.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
let status = parts[0].chars().next().unwrap_or('M').to_string();
let path = parts.last().unwrap_or(&"").to_string();
status_map.insert(path, status);
}
}
// Update change types
for file in &mut files {
let base_path = if file.path.contains(" => ") {
file.path
.split(" => ")
.last()
.unwrap_or(&file.path)
.replace(['{', '}'], "")
} else {
file.path.clone()
};
if let Some(status) = status_map
.get(&base_path)
.or_else(|| status_map.get(&file.path))
{
file.change_type = status.clone();
}
}
Ok(GitCommitFilesResponse { files })
}
#[tauri::command]
pub async fn get_git_identities() -> Result<Vec<GitIdentityProfile>, String> {
load_identities().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_git_identity(
profile: GitIdentityProfile,
) -> Result<GitIdentityProfile, String> {
let mut profiles = load_identities().await.map_err(|e| e.to_string())?;
if profiles.iter().any(|p| p.id == profile.id) {
return Err(format!("Profile with ID {} already exists", profile.id));
}
profiles.push(profile.clone());
save_identities(profiles).await.map_err(|e| e.to_string())?;
Ok(profile)
}
#[tauri::command]
pub async fn update_git_identity(
id: String,
updates: GitIdentityProfile,
) -> Result<GitIdentityProfile, String> {
let mut profiles = load_identities().await.map_err(|e| e.to_string())?;
if let Some(idx) = profiles.iter().position(|p| p.id == id) {
profiles[idx] = updates.clone();
save_identities(profiles).await.map_err(|e| e.to_string())?;
Ok(updates)
} else {
Err(format!("Profile with ID {} not found", id))
}
}
#[tauri::command]
pub async fn delete_git_identity(id: String) -> Result<(), String> {
let mut profiles = load_identities().await.map_err(|e| e.to_string())?;
let len = profiles.len();
profiles.retain(|p| p.id != id);
if profiles.len() == len {
return Err(format!("Profile with ID {} not found", id));
}
save_identities(profiles).await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_remote_url(
directory: String,
remote: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<Option<String>, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let remote_name = remote.unwrap_or_else(|| "origin".to_string());
let url = run_git(&["remote", "get-url", &remote_name], &root)
.await
.ok();
Ok(url.filter(|s| !s.is_empty()))
}
#[tauri::command]
pub async fn get_current_git_identity(
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<GitIdentitySummary, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let user_name = run_git(&["config", "user.name"], &root).await.ok();
let user_email = run_git(&["config", "user.email"], &root).await.ok();
let ssh_command = run_git(&["config", "core.sshCommand"], &root).await.ok();
Ok(GitIdentitySummary {
user_name: user_name.filter(|s| !s.is_empty()),
user_email: user_email.filter(|s| !s.is_empty()),
ssh_command: ssh_command.filter(|s| !s.is_empty()),
})
}
#[tauri::command]
pub async fn has_local_identity(
directory: String,
state: State<'_, DesktopRuntime>,
) -> Result<bool, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let user_name = run_git(&["config", "--local", "--get", "user.name"], &root)
.await
.ok()
.filter(|s| !s.is_empty());
let user_email = run_git(&["config", "--local", "--get", "user.email"], &root)
.await
.ok()
.filter(|s| !s.is_empty());
Ok(user_name.is_some() || user_email.is_some())
}
#[tauri::command]
pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> {
let user_name = tokio::process::Command::new("git")
.args(["config", "--global", "user.name"])
.output()
.await
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let user_email = tokio::process::Command::new("git")
.args(["config", "--global", "user.email"])
.output()
.await
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let ssh_command = tokio::process::Command::new("git")
.args(["config", "--global", "core.sshCommand"])
.output()
.await
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(GitIdentitySummary {
user_name,
user_email,
ssh_command,
})
}
#[tauri::command]
pub async fn set_git_identity(
directory: String,
profile_id: String,
state: State<'_, DesktopRuntime>,
) -> Result<GitIdentityProfile, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
let profiles = load_identities().await.map_err(|e| e.to_string())?;
let profile = profiles
.into_iter()
.find(|p| p.id == profile_id)
.ok_or_else(|| format!("Profile {} not found", profile_id))?;
run_git(
&["config", "--local", "user.name", &profile.user_name],
&root,
)
.await
.map_err(|e| e.to_string())?;
run_git(
&["config", "--local", "user.email", &profile.user_email],
&root,
)
.await
.map_err(|e| e.to_string())?;
let auth_type = profile.auth_type.as_deref().unwrap_or("ssh");
if auth_type == "ssh" {
if let Some(key) = &profile.ssh_key {
let cmd = format!("ssh -i {}", key);
run_git(&["config", "--local", "core.sshCommand", &cmd], &root)
.await
.map_err(|e| e.to_string())?;
}
let _ = run_git(
&["config", "--local", "--unset", "credential.helper"],
&root,
)
.await;
} else if auth_type == "token" && profile.host.is_some() {
run_git(&["config", "--local", "credential.helper", "store"], &root)
.await
.map_err(|e| e.to_string())?;
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
} else {
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
}
Ok(profile)
}
#[tauri::command]
pub async fn discover_git_credentials() -> Result<Vec<DiscoveredGitCredential>, String> {
let home = dirs::home_dir().ok_or_else(|| "Could not find home directory".to_string())?;
let credentials_path = home.join(".git-credentials");
if !credentials_path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&credentials_path)
.await
.map_err(|e| format!("Failed to read .git-credentials: {}", e))?;
let mut credentials = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(url) = url::Url::parse(trimmed) {
let hostname = url.host_str().unwrap_or("").to_string();
let path = url.path();
let host = if path.is_empty() || path == "/" {
hostname
} else {
format!("{}{}", hostname, path)
};
let username = url.username().to_string();
if !host.is_empty() && !username.is_empty() {
let exists = credentials
.iter()
.any(|c: &DiscoveredGitCredential| c.host == host && c.username == username);
if !exists {
credentials.push(DiscoveredGitCredential { host, username });
}
}
}
}
Ok(credentials)
}
#[tauri::command]
pub async fn generate_commit_message(
directory: String,
files: Vec<String>,
state: State<'_, DesktopRuntime>,
) -> Result<CommitMessageResponse, String> {
let _root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
// 1. Collect diffs
let mut diff_summaries = String::new();
for file in files {
if let Ok(diff) =
get_git_diff(directory.clone(), file.clone(), None, None, state.clone()).await
{
let trimmed = if diff.len() > 4000 {
format!("{}\n...", &diff[..4000])
} else {
diff
};
diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, trimmed));
}
}
if diff_summaries.is_empty() {
return Err("No diffs available for selected files".to_string());
}
// 2. Construct prompt (matching server/index.js)
let prompt = format!(
r#"You are drafting git commit notes for this codebase. Respond in JSON of the shape {{"subject": string, "highlights": string[]}} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:
- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")
- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)
- summary must be imperative, concise, <= 70 characters, no trailing punctuation
- scope is optional; include only when obvious from filenames/folders; do not invent scopes
- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes
- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.
- text must be plain (no markdown bullets); each highlight should start with an uppercase verb
Diff summary:
{}"#,
diff_summaries
);
let model = "gpt-5-nano";
// 3. Call API
let client = Client::new();
let res = client
.post("https://opencode.ai/zen/v1/responses")
.json(&serde_json::json!({
"model": model,
"input": [{ "role": "user", "content": prompt }],
"max_output_tokens": 1000,
"stream": false,
"reasoning": {
"effort": "low"
}
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("API request failed: {}", res.status()));
}
let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let raw_content = body["output"]
.as_array()
.and_then(|items| items.iter().find(|item| item["type"] == "message"))
.and_then(|item| item["content"].as_array())
.and_then(|content| content.iter().find(|entry| entry["type"] == "output_text"))
.and_then(|entry| entry["text"].as_str())
.unwrap_or("")
.trim();
// 4. Parse JSON
// Strip markdown code blocks if present
let cleaned = raw_content
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let extracted = extract_json_object(cleaned);
let mut last_error: Option<String> = None;
if let Some(candidate) = extracted.as_deref() {
if candidate.starts_with('{') || candidate.starts_with('[') {
match serde_json::from_str::<GeneratedCommitMessage>(candidate) {
Ok(message) => return Ok(CommitMessageResponse { message }),
Err(err) => last_error = Some(err.to_string()),
}
}
}
if cleaned.starts_with('{') || cleaned.starts_with('[') {
match serde_json::from_str::<GeneratedCommitMessage>(cleaned) {
Ok(message) => return Ok(CommitMessageResponse { message }),
Err(err) => last_error = Some(err.to_string()),
}
}
Err(format!(
"Failed to parse AI response: {}",
last_error.unwrap_or_else(|| "unknown error".to_string())
))
}
#[tauri::command]
pub async fn generate_pr_description(
directory: String,
base: String,
head: String,
context: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<serde_json::Value, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
if base.trim().is_empty() || head.trim().is_empty() {
return Err("base and head are required".to_string());
}
// 1. Collect PR range diffs (base...head)
let base_ref = base.trim();
let head_ref = head.trim();
let origin_candidate = format!("refs/remotes/origin/{}", base_ref);
let resolved_base = if run_git(&["rev-parse", "--verify", &origin_candidate], &root).await.is_ok() {
format!("origin/{}", base_ref)
} else {
base_ref.to_string()
};
let range = format!("{}...{}", resolved_base, head_ref);
let files = {
let args = vec!["diff", "--name-only", range.as_str()];
let raw = run_git(&args, &root).await.unwrap_or_default();
raw.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect::<Vec<String>>()
};
if files.is_empty() {
return Err("No diffs available for base...head".to_string());
}
let mut diff_summaries = String::new();
for file in files.iter() {
let context = "-U3";
let args = vec![
"diff",
"--no-color",
context,
range.as_str(),
"--",
file.as_str(),
];
if let Ok(diff) = run_git(&args, &root).await {
if !diff.trim().is_empty() {
diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, diff));
}
}
}
if diff_summaries.is_empty() {
return Err("No diffs available for selected files".to_string());
}
// 2. Construct PR-specific prompt
let mut prompt = format!(
r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{"title": string, "body": string}} (ONLY JSON in response, no markdown fences) with these rules:
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\")
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
- Testing: bullet list (\"- Not tested\" allowed)
- Notes: bullet list; include breaking/rollout notes only when relevant
Context:
- base branch: {base}
- head branch: {head}"#,
base = base.trim(),
head = head.trim()
);
// Include additional context if provided
if let Some(ctx) = context {
let trimmed = ctx.trim();
if !trimmed.is_empty() {
prompt.push_str(&format!("\n\nAdditional context provided by user:\n{}", trimmed));
}
}
prompt.push_str(&format!("\n\nDiff summary:\n{}", diff_summaries));
let model = "gpt-5-nano";
// 3. Call API
let client = Client::new();
let res = client
.post("https://opencode.ai/zen/v1/responses")
.json(&serde_json::json!({
"model": model,
"input": [{ "role": "user", "content": prompt }],
"max_output_tokens": 1200,
"stream": false,
"reasoning": { "effort": "low" }
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("API request failed: {}", res.status()));
}
let body_json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let raw_content = body_json["output"]
.as_array()
.and_then(|items| items.iter().find(|item| item["type"] == "message"))
.and_then(|item| item["content"].as_array())
.and_then(|content| content.iter().find(|entry| entry["type"] == "output_text"))
.and_then(|entry| entry["text"].as_str())
.unwrap_or("")
.trim();
if raw_content.is_empty() {
return Err("No PR description returned by generator".to_string());
}
let cleaned = raw_content
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let extracted = extract_json_object(cleaned);
let candidates = [
Some(cleaned.to_string()),
extracted,
Some(raw_content.to_string()),
];
for candidate in candidates.iter().flatten() {
if !(candidate.starts_with('{') || candidate.starts_with('[')) {
continue;
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(candidate) {
let title = parsed.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = parsed.get("body").and_then(|v| v.as_str()).unwrap_or("");
return Ok(serde_json::json!({ "title": title, "body": body }));
}
}
Ok(serde_json::json!({ "title": "", "body": raw_content }))
}