use std::collections::{hash_map::DefaultHasher, HashMap, HashSet}; use std::fs::OpenOptions; use std::hash::{Hash, Hasher}; use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::process::Stdio; use std::sync::{mpsc, LazyLock, Mutex}; use std::time::{Duration, Instant, UNIX_EPOCH}; use base64::Engine as _; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde::Serialize; use tokio::sync::Semaphore; use walkdir::WalkDir; #[cfg(feature = "tauri-runtime")] use tauri::Manager; use crate::app_error::AppCommandError; #[cfg(feature = "tauri-runtime")] use crate::db::error::DbError; use crate::db::service::folder_service; use crate::db::AppDatabase; use crate::models::GitCredentials; #[cfg(feature = "tauri-runtime")] use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation}; use crate::web::event_bridge::EventEmitter; /// Configure a git command for remote operations: /// - Always disable interactive prompts (prevent hanging in a GUI app) /// - If explicit credentials are provided, use them directly /// - Otherwise, try to inject stored account credentials async fn prepare_remote_git_cmd( cmd: &mut tokio::process::Command, repo_path: &str, credentials: Option<&GitCredentials>, db: &AppDatabase, data_dir: &std::path::Path, ) { prepare_remote_git_cmd_with_remote(cmd, repo_path, None, credentials, db, data_dir).await; } /// Same as `prepare_remote_git_cmd` but allows specifying a remote name /// to match credentials against the correct remote URL. async fn prepare_remote_git_cmd_with_remote( cmd: &mut tokio::process::Command, repo_path: &str, remote_name: Option<&str>, credentials: Option<&GitCredentials>, db: &AppDatabase, data_dir: &std::path::Path, ) { cmd.env("GIT_TERMINAL_PROMPT", "0") .stdin(Stdio::null()); if let Some(creds) = credentials { // Explicit credentials provided (e.g. from credential dialog) if let Ok(askpass) = crate::git_credential::ensure_askpass_script(data_dir) { crate::git_credential::inject_credentials( cmd, &creds.username, &creds.password, &askpass, ); } } else { // Fall back to stored accounts, matching against the specified remote crate::git_credential::try_inject_for_repo_remote( cmd, repo_path, remote_name, &db.conn, data_dir, ) .await; } } /// Same as `prepare_remote_git_cmd` but for clone (URL only, no repo yet). async fn prepare_remote_git_cmd_for_url( cmd: &mut tokio::process::Command, clone_url: &str, credentials: Option<&GitCredentials>, db: &AppDatabase, data_dir: &std::path::Path, ) { cmd.env("GIT_TERMINAL_PROMPT", "0") .stdin(Stdio::null()); if let Some(creds) = credentials { if let Ok(askpass) = crate::git_credential::ensure_askpass_script(data_dir) { crate::git_credential::inject_credentials( cmd, &creds.username, &creds.password, &askpass, ); } } else { crate::git_credential::try_inject_for_url(cmd, clone_url, &db.conn, data_dir).await; } } /// Classify a git remote command error, detecting authentication failures. fn classify_remote_git_error(operation: &str, stderr: &[u8]) -> AppCommandError { let msg = String::from_utf8_lossy(stderr).trim().to_string(); eprintln!("[GIT_CMD] {} failed, stderr: {}", operation, msg); let lower = msg.to_lowercase(); if lower.contains("authentication failed") || lower.contains("invalid credentials") || lower.contains("could not read username") || lower.contains("could not read password") || lower.contains("logon failed") || lower.contains("terminal prompts disabled") || lower.contains("the requested url returned error: 401") || lower.contains("the requested url returned error: 403") || lower.contains("http basic: access denied") { return AppCommandError::authentication_failed(format!( "git {operation}: authentication failed. Configure a GitHub account in Settings → Version Control." )) .with_detail(msg); } if lower.contains("could not resolve host") || lower.contains("unable to access") || lower.contains("connection refused") || lower.contains("network is unreachable") { return AppCommandError::network(format!("git {operation}: network error")) .with_detail(msg); } AppCommandError::external_command(format!("git {operation} failed"), msg) } #[derive(Debug, Serialize)] pub struct GitStatusEntry { pub status: String, pub file: String, } #[derive(Debug, Serialize)] pub struct GitBranchList { pub local: Vec, pub remote: Vec, pub worktree_branches: Vec, } #[derive(Debug, Serialize)] pub struct GitConflictInfo { pub has_conflicts: bool, pub conflicted_files: Vec, pub operation: String, pub upstream_commit: Option, } #[derive(Debug, Serialize)] pub struct GitPullResult { pub updated_files: usize, pub conflict: Option, } #[derive(Debug, Serialize)] pub struct GitPushResult { pub pushed_commits: usize, pub upstream_set: bool, } #[derive(Debug, Serialize)] pub struct GitPushInfo { pub branch: String, pub remotes: Vec, pub tracking_remote: Option, } #[derive(Debug, Serialize)] pub struct GitMergeResult { pub merged_commits: usize, pub conflict: Option, } #[derive(Debug, Serialize)] pub struct GitRebaseResult { pub message: String, pub conflict: Option, } #[derive(Debug, Serialize)] pub struct GitConflictFileVersions { pub base: String, pub ours: String, pub theirs: String, pub merged: String, } #[derive(Debug, Serialize)] pub struct GitCommitResult { pub committed_files: usize, } #[derive(Debug, Serialize)] pub struct GitStashEntry { pub index: usize, pub message: String, pub branch: String, pub date: String, pub ref_name: String, } #[derive(Debug, Serialize)] pub struct GitRemote { pub name: String, pub url: String, } #[derive(Debug, Clone, Serialize)] struct GitCommitSucceededEvent { folder_id: i32, committed_files: usize, } #[derive(Debug, Clone, Serialize)] struct GitPushSucceededEvent { folder_id: i32, pushed_commits: usize, upstream_set: bool, } struct FileWatchEntry { root_canonical: PathBuf, root_display: String, watcher: RecommendedWatcher, worker: Option>, ref_count: usize, } #[derive(Debug, Clone, Serialize)] struct FileTreeChangedEvent { root_path: String, changed_paths: Vec, kind: String, full_reload: bool, refresh_git_status: bool, } #[derive(Debug, Clone, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum FileTreeNode { File { name: String, path: String, }, Dir { name: String, path: String, children: Vec, }, } #[derive(Debug, Serialize)] pub struct FilePreviewContent { pub path: String, pub content: String, } #[derive(Debug, Serialize)] pub struct FileEditContent { pub path: String, pub content: String, pub etag: String, pub mtime_ms: Option, pub readonly: bool, pub line_ending: String, } #[derive(Debug, Serialize)] pub struct FileSaveResult { pub path: String, pub etag: String, pub mtime_ms: Option, pub readonly: bool, pub line_ending: String, } #[derive(Debug, Serialize)] pub struct GitLogEntry { pub hash: String, pub full_hash: String, pub author: String, pub date: String, pub message: String, pub files: Vec, pub pushed: Option, } #[derive(Debug, Serialize)] pub struct GitLogFileChange { pub path: String, pub status: String, pub additions: u32, pub deletions: u32, } #[derive(Debug, Serialize)] pub struct GitLogResult { pub entries: Vec, pub has_upstream: bool, } fn count_non_empty_lines(content: &str) -> usize { content .lines() .map(str::trim) .filter(|line| !line.is_empty()) .count() } fn parse_count_from_output(stdout: &[u8]) -> Option { String::from_utf8_lossy(stdout).trim().parse::().ok() } fn git_command_error(operation: &str, stderr: &[u8]) -> AppCommandError { let stderr = String::from_utf8_lossy(stderr).trim().to_string(); AppCommandError::external_command(format!("git {operation} failed"), stderr) } async fn detect_conflicts(path: &str) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["-c", "core.quotePath=false"]) .args(["diff", "--name-only", "--diff-filter=U"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Ok(vec![]); } Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(unquote_git_path) .filter(|l| !l.is_empty()) .collect()) } async fn get_head_hash(path: &str) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["rev-parse", "HEAD"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Ok(None); } let head = String::from_utf8_lossy(&output.stdout).trim().to_string(); if head.is_empty() { return Ok(None); } Ok(Some(head)) } async fn count_files_in_commit(path: &str, commit: &str) -> Result { let output = crate::process::tokio_command("git") .args(["show", "--name-only", "--pretty=format:", commit]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("show", &output.stderr)); } Ok(count_non_empty_lines(&String::from_utf8_lossy( &output.stdout, ))) } async fn count_changed_files_between( path: &str, base: &str, head: &str, ) -> Result { let range = format!("{}..{}", base, head); let output = crate::process::tokio_command("git") .args(["diff", "--name-only", &range]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("diff", &output.stderr)); } Ok(count_non_empty_lines(&String::from_utf8_lossy( &output.stdout, ))) } async fn estimate_push_commit_count(path: &str) -> usize { let upstream_ahead = crate::process::tokio_command("git") .args(["rev-list", "--count", "@{push}..HEAD"]) .current_dir(path) .output() .await; if let Ok(output) = upstream_ahead { if output.status.success() { if let Some(count) = parse_count_from_output(&output.stdout) { return count; } } } let branch_output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(path) .output() .await; let Ok(branch_output) = branch_output else { return 0; }; if !branch_output.status.success() { return 0; } let branch = String::from_utf8_lossy(&branch_output.stdout) .trim() .to_string(); if branch.is_empty() || branch == "HEAD" { return 0; } let remote_key = format!("branch.{}.remote", branch); let remote_output = crate::process::tokio_command("git") .args(["config", "--get", &remote_key]) .current_dir(path) .output() .await; let remote = remote_output .ok() .filter(|output| output.status.success()) .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "origin".to_string()); let remote_arg = format!("--remotes={}", remote); let output = crate::process::tokio_command("git") .args(["rev-list", "--count", "HEAD", "--not", &remote_arg]) .current_dir(path) .output() .await; let Ok(output) = output else { return 0; }; if !output.status.success() { return 0; } parse_count_from_output(&output.stdout).unwrap_or(0) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_folder( db: tauri::State<'_, AppDatabase>, folder_id: i32, ) -> Result { folder_service::get_folder_by_id(&db.conn, folder_id) .await? .ok_or_else(|| DbError::Migration(format!("Folder {} not found", folder_id))) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn load_folder_history( db: tauri::State<'_, AppDatabase>, ) -> Result, AppCommandError> { folder_service::list_folders(&db.conn) .await .map_err(AppCommandError::from) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn add_folder_to_history( db: tauri::State<'_, AppDatabase>, path: String, ) -> Result { folder_service::add_folder(&db.conn, &path).await } pub(crate) async fn set_folder_parent_branch_core( conn: &sea_orm::DatabaseConnection, path: &str, parent_branch: Option, ) -> Result<(), AppCommandError> { use crate::db::entities::folder; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let row = folder::Entity::find() .filter(folder::Column::Path.eq(path)) .filter(folder::Column::DeletedAt.is_null()) .one(conn) .await .map_err(|e| { AppCommandError::database_error("Failed to query folder").with_detail(e.to_string()) })?; if let Some(folder_model) = row { folder_service::set_folder_parent_branch(conn, folder_model.id, parent_branch) .await .map_err(AppCommandError::from)?; } Ok(()) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn set_folder_parent_branch( db: tauri::State<'_, AppDatabase>, path: String, parent_branch: Option, ) -> Result<(), AppCommandError> { set_folder_parent_branch_core(&db.conn, &path, parent_branch).await } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn remove_folder_from_history( db: tauri::State<'_, AppDatabase>, path: String, ) -> Result<(), AppCommandError> { folder_service::remove_folder(&db.conn, &path) .await .map_err(AppCommandError::from) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn save_folder_opened_conversations( db: tauri::State<'_, AppDatabase>, folder_id: i32, items: Vec, ) -> Result<(), DbError> { folder_service::save_opened_conversations(&db.conn, folder_id, items).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn create_folder_directory(path: String) -> Result<(), AppCommandError> { std::fs::create_dir_all(&path).map_err(AppCommandError::io) } pub(crate) async fn clone_repository_core( url: &str, target_dir: &str, credentials: Option<&GitCredentials>, db: &AppDatabase, data_dir: &std::path::Path, ) -> Result<(), AppCommandError> { if url.trim().is_empty() || target_dir.trim().is_empty() { return Err(AppCommandError::invalid_input( "Repository URL and target directory are required", )); } let mut cmd = crate::process::tokio_command("git"); cmd.args(["clone", url, target_dir]); prepare_remote_git_cmd_for_url(&mut cmd, url, credentials, db, data_dir).await; let output = cmd .output() .await .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { AppCommandError::dependency_missing( "Git is not installed. Please install Git first.", ) .with_detail("https://git-scm.com") } else { AppCommandError::external_command("Failed to run git clone", e.to_string()) } })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(classify_git_clone_error(stderr.trim())); } Ok(()) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn clone_repository( url: String, target_dir: String, credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result<(), AppCommandError> { let data_dir = app_handle.path().app_data_dir().map_err(|e| { AppCommandError::external_command("Failed to resolve app data dir", e.to_string()) })?; clone_repository_core(&url, &target_dir, credentials.as_ref(), &db, &data_dir).await } fn classify_git_clone_error(stderr: &str) -> AppCommandError { let normalized = stderr.to_lowercase(); if normalized.contains("already exists and is not an empty directory") { return AppCommandError::already_exists("Target directory already exists and is not empty") .with_detail(stderr.to_string()); } if normalized.contains("repository not found") { return AppCommandError::not_found( "Repository not found. Check URL and access permissions.", ) .with_detail(stderr.to_string()); } if normalized.contains("could not resolve host") || normalized.contains("network is unreachable") || normalized.contains("connection timed out") || normalized.contains("failed to connect") { return AppCommandError::network("Network is unavailable while cloning repository") .with_detail(stderr.to_string()); } if normalized.contains("authentication failed") || normalized.contains("could not read username") || normalized.contains("could not read password") || normalized.contains("logon failed") || normalized.contains("terminal prompts disabled") || normalized.contains("the requested url returned error: 401") || normalized.contains("the requested url returned error: 403") || normalized.contains("http basic: access denied") || normalized.contains("permission denied (publickey)") { return AppCommandError::authentication_failed( "Authentication failed while cloning repository", ) .with_detail(stderr.to_string()); } if normalized.contains("permission denied") { return AppCommandError::permission_denied("Permission denied while cloning repository") .with_detail(stderr.to_string()); } AppCommandError::external_command("Git clone failed", stderr.to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_git_branch(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if output.status.success() { let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !branch.is_empty() && branch != "HEAD" { return Ok(Some(branch)); } } // Fallback: symbolic-ref works on unborn branches (after git init, before first commit) let sym_output = crate::process::tokio_command("git") .args(["symbolic-ref", "--short", "HEAD"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if sym_output.status.success() { let branch = String::from_utf8_lossy(&sym_output.stdout) .trim() .to_string(); if !branch.is_empty() { return Ok(Some(branch)); } } Ok(None) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_init(path: String) -> Result<(), AppCommandError> { let output = crate::process::tokio_command("git") .args(["init"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("init", &output.stderr)); } Ok(()) } pub(crate) async fn git_pull_core( path: &str, credentials: Option<&GitCredentials>, db: &AppDatabase, data_dir: &std::path::Path, ) -> Result { let head_before = get_head_hash(path).await?; // Step 1: fetch from remote let mut fetch_cmd = crate::process::tokio_command("git"); fetch_cmd.args(["fetch"]).current_dir(path); prepare_remote_git_cmd(&mut fetch_cmd, path, credentials, db, data_dir).await; let fetch_output = fetch_cmd .output() .await .map_err(AppCommandError::io)?; if !fetch_output.status.success() { return Err(classify_remote_git_error("fetch", &fetch_output.stderr)); } // Step 2: check if upstream exists let upstream_check = crate::process::tokio_command("git") .args(["rev-parse", "@{u}"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !upstream_check.status.success() { return Ok(GitPullResult { updated_files: 0, conflict: None, }); } let upstream_commit = String::from_utf8_lossy(&upstream_check.stdout) .trim() .to_string(); // Step 3: check if we can fast-forward let merge_base = crate::process::tokio_command("git") .args(["merge-base", "HEAD", "@{u}"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; let head_hash = crate::process::tokio_command("git") .args(["rev-parse", "HEAD"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; let base_hash = String::from_utf8_lossy(&merge_base.stdout).trim().to_string(); let current_head = String::from_utf8_lossy(&head_hash.stdout).trim().to_string(); if base_hash == current_head { let ff_output = crate::process::tokio_command("git") .args(["merge", "--ff-only", "@{u}"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !ff_output.status.success() { return Err(git_command_error("merge --ff-only", &ff_output.stderr)); } } else { let merge_output = crate::process::tokio_command("git") .args(["merge", "--no-commit", "@{u}"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !merge_output.status.success() { let conflicted_files = detect_conflicts(path).await?; if !conflicted_files.is_empty() { let _ = crate::process::tokio_command("git") .args(["merge", "--abort"]) .current_dir(path) .output() .await; return Ok(GitPullResult { updated_files: 0, conflict: Some(GitConflictInfo { has_conflicts: true, conflicted_files, operation: "pull".to_string(), upstream_commit: Some(upstream_commit), }), }); } return Err(git_command_error("merge", &merge_output.stderr)); } let commit_output = crate::process::tokio_command("git") .args(["commit", "--no-edit"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !commit_output.status.success() { let stderr = String::from_utf8_lossy(&commit_output.stderr); let stdout = String::from_utf8_lossy(&commit_output.stdout); if !stderr.contains("nothing to commit") && !stdout.contains("nothing to commit") { return Err(git_command_error("commit", &commit_output.stderr)); } } } let head_after = get_head_hash(path).await?; let updated_files = match (head_before.as_deref(), head_after.as_deref()) { (Some(before), Some(after)) if before != after => { count_changed_files_between(path, before, after).await? } (None, Some(after)) => count_files_in_commit(path, after).await?, _ => 0, }; Ok(GitPullResult { updated_files, conflict: None, }) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_pull( path: String, credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result { let data_dir = app_handle.path().app_data_dir().map_err(|e| { AppCommandError::external_command("Failed to resolve app data dir", e.to_string()) })?; git_pull_core(&path, credentials.as_ref(), &db, &data_dir).await } /// Start a merge with the upstream branch (used by merge workspace after pull conflict detection). /// This recreates the conflict state so that :1:, :2:, :3: stage entries are available. /// If `upstream_commit` is provided, merge against that specific commit instead of `@{u}`. #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_start_pull_merge( path: String, upstream_commit: Option, ) -> Result<(), AppCommandError> { let target = upstream_commit.as_deref().unwrap_or("@{u}"); let output = crate::process::tokio_command("git") .args(["merge", "--no-commit", target]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; // It's expected to fail with conflicts — that's the point. // We just need the merge state to be active so stage entries exist. if !output.status.success() { let conflicted_files = detect_conflicts(&path).await?; if !conflicted_files.is_empty() { return Ok(()); // Conflict state is now active — merge workspace can proceed } return Err(git_command_error("merge", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_has_merge_head(path: String) -> Result { let output = crate::process::tokio_command("git") .args(["rev-parse", "--verify", "MERGE_HEAD"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; Ok(output.status.success()) } pub(crate) async fn git_fetch_core( path: &str, credentials: Option<&GitCredentials>, db: &AppDatabase, data_dir: &std::path::Path, ) -> Result { let mut cmd = crate::process::tokio_command("git"); cmd.args(["fetch", "--all"]).current_dir(path); prepare_remote_git_cmd(&mut cmd, path, credentials, db, data_dir).await; let output = cmd .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(classify_remote_git_error("fetch --all", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_fetch( path: String, credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result { let data_dir = app_handle.path().app_data_dir().map_err(|e| { AppCommandError::external_command("Failed to resolve app data dir", e.to_string()) })?; git_fetch_core(&path, credentials.as_ref(), &db, &data_dir).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_push_info(path: String) -> Result { // Get current branch name let branch_output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; let branch = String::from_utf8_lossy(&branch_output.stdout) .trim() .to_string(); // Get tracking remote for current branch let remote_key = format!("branch.{}.remote", branch); let remote_output = crate::process::tokio_command("git") .args(["config", "--get", &remote_key]) .current_dir(&path) .output() .await; let tracking_remote = remote_output .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|v| !v.is_empty()); // Get all remotes let remotes = git_list_remotes(path).await?; Ok(GitPushInfo { branch, remotes, tracking_remote, }) } pub(crate) async fn git_push_core( data_dir: &std::path::Path, emitter: &EventEmitter, folder_id: Option, path: &str, remote: Option<&str>, credentials: Option<&GitCredentials>, db: &AppDatabase, ) -> Result { let pushed_commits = estimate_push_commit_count(path).await; let target_remote = remote .filter(|s| !s.is_empty()) .unwrap_or("origin"); let branch_output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; let branch = String::from_utf8_lossy(&branch_output.stdout) .trim() .to_string(); let upstream_check = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; let current_upstream = if upstream_check.status.success() { Some( String::from_utf8_lossy(&upstream_check.stdout) .trim() .to_string(), ) } else { None }; let needs_set_upstream = match ¤t_upstream { None => true, Some(upstream) => !upstream.starts_with(&format!("{}/", target_remote)), }; let output = if needs_set_upstream { let mut cmd = crate::process::tokio_command("git"); cmd.args(["push", "--set-upstream", target_remote, &branch]) .current_dir(path); prepare_remote_git_cmd_with_remote(&mut cmd, path, Some(target_remote), credentials, db, data_dir).await; cmd.output().await.map_err(AppCommandError::io)? } else { let mut cmd = crate::process::tokio_command("git"); cmd.args(["push", target_remote, &branch]) .current_dir(path); prepare_remote_git_cmd_with_remote(&mut cmd, path, Some(target_remote), credentials, db, data_dir).await; cmd.output().await.map_err(AppCommandError::io)? }; if !output.status.success() { return Err(classify_remote_git_error("push", &output.stderr)); } let upstream_set = needs_set_upstream; if let Some(folder_id) = folder_id { crate::web::event_bridge::emit_event( emitter, "folder://git-push-succeeded", GitPushSucceededEvent { folder_id, pushed_commits, upstream_set, }, ); } Ok(GitPushResult { pushed_commits, upstream_set, }) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_push( app: tauri::AppHandle, window: tauri::WebviewWindow, path: String, remote: Option, credentials: Option, db: tauri::State<'_, AppDatabase>, ) -> Result { let folder_id = window .label() .strip_prefix("push-") .and_then(|value| value.parse::().ok()); let data_dir = app.path().app_data_dir().map_err(|e| { AppCommandError::external_command("Failed to resolve app data dir", e.to_string()) })?; let emitter = EventEmitter::Tauri(app.clone()); git_push_core(&data_dir, &emitter, folder_id, &path, remote.as_deref(), credentials.as_ref(), &db).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_new_branch( path: String, branch_name: String, start_point: Option, ) -> Result<(), AppCommandError> { let mut args = vec!["checkout".to_string(), "-b".to_string(), branch_name]; if let Some(start_point) = start_point { let trimmed = start_point.trim(); if !trimmed.is_empty() { args.push(trimmed.to_string()); } } let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("checkout -b", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_worktree_add( path: String, branch_name: String, worktree_path: String, ) -> Result<(), AppCommandError> { // 校验分支是否已存在 let check = crate::process::tokio_command("git") .args([ "rev-parse", "--verify", &format!("refs/heads/{}", branch_name), ]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if check.status.success() { return Err( AppCommandError::already_exists("Branch already exists").with_detail(branch_name) ); } // 校验目录是否已存在 if std::path::Path::new(&worktree_path).exists() { return Err( AppCommandError::already_exists("Worktree directory already exists") .with_detail(worktree_path), ); } // 执行 git worktree add -b let output = crate::process::tokio_command("git") .args(["worktree", "add", "-b", &branch_name, &worktree_path]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("worktree add", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_checkout(path: String, branch_name: String) -> Result<(), AppCommandError> { let output = crate::process::tokio_command("git") .args(["checkout", &branch_name]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("checkout", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_list_branches(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["branch", "--format=%(refname:short)"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("branch", &output.stderr)); } let branches = String::from_utf8_lossy(&output.stdout) .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect(); Ok(branches) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_stash_push( path: String, message: Option, keep_index: bool, ) -> Result { let mut args = vec!["stash".to_string(), "push".to_string()]; if let Some(msg) = message { if !msg.is_empty() { args.push("-m".to_string()); args.push(msg); } } if keep_index { args.push("--keep-index".to_string()); } let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("stash push", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_stash_pop( path: String, stash_ref: Option, ) -> Result { let mut args = vec!["stash", "pop"]; let stash_ref_val; if let Some(ref r) = stash_ref { stash_ref_val = r.clone(); args.push(&stash_ref_val); } let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("stash pop", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_stash_list(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["stash", "list", "--format=%gd||%gs||%ci"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("stash list", &output.stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let entries = stdout .lines() .filter(|l| !l.is_empty()) .enumerate() .filter_map(|(i, line)| { let parts: Vec<&str> = line.splitn(3, "||").collect(); if parts.len() < 3 { return None; } let ref_name = parts[0].to_string(); let subject = parts[1]; let date = parts[2].to_string(); // Parse branch and message from subject like "On branch: message" or "WIP on branch: hash" let (branch, message) = if let Some(rest) = subject.strip_prefix("On ") { if let Some(colon_pos) = rest.find(": ") { let branch = rest[..colon_pos].to_string(); let msg = rest[colon_pos + 2..].to_string(); (branch, msg) } else { (String::new(), subject.to_string()) } } else if let Some(rest) = subject.strip_prefix("WIP on ") { if let Some(colon_pos) = rest.find(": ") { let branch = rest[..colon_pos].to_string(); let msg = rest[colon_pos + 2..].to_string(); (branch, msg) } else { (String::new(), subject.to_string()) } } else { (String::new(), subject.to_string()) }; Some(GitStashEntry { index: i, message, branch, date, ref_name, }) }) .collect(); Ok(entries) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_stash_apply( path: String, stash_ref: String, ) -> Result { let output = crate::process::tokio_command("git") .args(["stash", "apply", &stash_ref]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("stash apply", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_stash_drop( path: String, stash_ref: String, ) -> Result { let output = crate::process::tokio_command("git") .args(["stash", "drop", &stash_ref]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("stash drop", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_stash_clear(path: String) -> Result { let output = crate::process::tokio_command("git") .args(["stash", "clear"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("stash clear", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_stash_show( path: String, stash_ref: String, ) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["-c", "core.quotePath=false"]) .args(["stash", "show", "--name-status", &stash_ref]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("stash show", &output.stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let entries = stdout .lines() .filter(|l| !l.is_empty()) .filter_map(|line| { let mut parts = line.splitn(2, '\t'); let status = parts.next()?.trim().to_string(); let file = unquote_git_path(parts.next()?); Some(GitStatusEntry { status, file }) }) .collect(); Ok(entries) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_status( path: String, show_all_untracked: Option, ) -> Result, AppCommandError> { let untracked_mode = if show_all_untracked.unwrap_or(false) { "-uall" } else { "-unormal" }; let output = crate::process::tokio_command("git") .args(["-c", "core.quotePath=false"]) .args(["status", "--porcelain=v1", untracked_mode]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("status", &output.stderr)); } let entries = String::from_utf8_lossy(&output.stdout) .lines() .filter(|l| !l.is_empty()) .map(|line| { let status = line[..2].trim().to_string(); let file = unquote_git_path(&line[3..]); GitStatusEntry { status, file } }) .collect(); Ok(entries) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_is_tracked(path: String, file: String) -> Result { let literal_file = to_git_literal_pathspec(&file); let output = crate::process::tokio_command("git") .args(["ls-files", "--error-unmatch", "--"]) .arg(&literal_file) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; Ok(output.status.success()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_diff(path: String, file: Option) -> Result { let literal_file = file.as_deref().map(to_git_literal_pathspec); let mut args = vec!["diff".to_string(), "HEAD".to_string()]; if let Some(ref f) = literal_file { args.push("--".to_string()); args.push(f.clone()); } let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { // For new repos with no HEAD, fall back to diff --cached let mut fallback_args = vec!["diff".to_string(), "--cached".to_string()]; if let Some(ref f) = literal_file { fallback_args.push("--".to_string()); fallback_args.push(f.clone()); } let fallback = crate::process::tokio_command("git") .args(&fallback_args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; return Ok(String::from_utf8_lossy(&fallback.stdout).to_string()); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_diff_with_branch( path: String, branch: String, file: Option, ) -> Result { let target_branch = branch.trim(); if target_branch.is_empty() { return Err(AppCommandError::invalid_input( "Branch name cannot be empty", )); } let literal_file = file.as_deref().map(to_git_literal_pathspec); let mut args = vec![ "diff".to_string(), "--no-color".to_string(), target_branch.to_string(), ]; if let Some(ref f) = literal_file { args.push("--".to_string()); args.push(f.clone()); } let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppCommandError::external_command( "git diff failed", format!("branch={target_branch}; {stderr}"), )); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_show_diff( path: String, commit: String, file: Option, ) -> Result { let literal_file = file.as_deref().map(to_git_literal_pathspec); let mut args = vec![ "show".to_string(), "--no-color".to_string(), "--format=".to_string(), commit, ]; if let Some(ref f) = literal_file { args.push("--".to_string()); args.push(f.clone()); } let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("show", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_show_file( path: String, file: String, ref_name: Option, ) -> Result { let git_ref = ref_name.unwrap_or_else(|| "HEAD".to_string()); let file_spec = format!("{}:{}", git_ref, file); let output = crate::process::tokio_command("git") .args(["show", &file_spec]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { // File doesn't exist at this ref (e.g. new/untracked file) — return empty return Ok(String::new()); } let bytes = &output.stdout; if bytes.iter().take(2048).any(|b| *b == 0) { return Err( AppCommandError::invalid_input("Binary files are not supported").with_detail(file_spec), ); } Ok(String::from_utf8_lossy(bytes).to_string()) } pub(crate) async fn git_commit_core( emitter: &EventEmitter, folder_id: Option, conn: &sea_orm::DatabaseConnection, path: &str, message: &str, files: &[String], ) -> Result { // Stage selected files let mut add_args = vec!["add".to_string(), "--".to_string()]; add_args.extend(files.iter().map(|file| to_git_literal_pathspec(file))); let add_output = crate::process::tokio_command("git") .args(&add_args) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !add_output.status.success() { return Err(git_command_error("add", &add_output.stderr)); } // Resolve commit author from matching account (e.g. GitHub username) let author_override = crate::git_credential::resolve_commit_author(path, conn).await; // Commit let mut commit_cmd = crate::process::tokio_command("git"); if let Some((ref name, ref email)) = author_override { commit_cmd.args([ "-c", &format!("user.name={name}"), "-c", &format!("user.email={email}"), ]); } commit_cmd.args(["commit", "-m", message]).current_dir(path); let commit_output = commit_cmd .output() .await .map_err(AppCommandError::io)?; if !commit_output.status.success() { return Err(git_command_error("commit", &commit_output.stderr)); } let committed_files = count_files_in_commit(path, "HEAD") .await .unwrap_or(files.len()); if let Some(folder_id) = folder_id { crate::web::event_bridge::emit_event( emitter, "folder://git-commit-succeeded", GitCommitSucceededEvent { folder_id, committed_files, }, ); } Ok(GitCommitResult { committed_files }) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_commit( app: tauri::AppHandle, window: tauri::WebviewWindow, db: tauri::State<'_, AppDatabase>, path: String, message: String, files: Vec, ) -> Result { let folder_id = window .label() .strip_prefix("commit-") .and_then(|value| value.parse::().ok()); let emitter = EventEmitter::Tauri(app.clone()); git_commit_core(&emitter, folder_id, &db.conn, &path, &message, &files).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_rollback_file(path: String, file: String) -> Result<(), AppCommandError> { let target = file.trim(); if target.is_empty() { return Err(AppCommandError::invalid_input("File path cannot be empty")); } let literal_file = to_git_literal_pathspec(target); let restore_output = crate::process::tokio_command("git") .args([ "restore", "--source=HEAD", "--staged", "--worktree", "--", &literal_file, ]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if restore_output.status.success() { return Ok(()); } let restore_stderr = String::from_utf8_lossy(&restore_output.stderr) .trim() .to_string(); let restore_stderr_lower = restore_stderr.to_lowercase(); let supports_restore = !restore_stderr_lower.contains("unknown option") && !restore_stderr_lower.contains("unknown switch") && !restore_stderr_lower.contains("not a git command") && !restore_stderr_lower.contains("did you mean"); if supports_restore { return Err(AppCommandError::external_command( "git restore failed", restore_stderr, )); } let _ = crate::process::tokio_command("git") .args(["reset", "HEAD", "--", &literal_file]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; let checkout_output = crate::process::tokio_command("git") .args(["checkout", "--", &literal_file]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !checkout_output.status.success() { return Err(git_command_error("checkout --", &checkout_output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_add_files(path: String, files: Vec) -> Result<(), AppCommandError> { if files.is_empty() { return Ok(()); } let mut args = vec!["add".to_string(), "--".to_string()]; args.extend(files.iter().map(|file| to_git_literal_pathspec(file))); let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("add", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_list_all_branches(path: String) -> Result { let local_fut = crate::process::tokio_command("git") .args(["branch", "--format=%(refname:short)"]) .current_dir(&path) .output(); let remote_fut = crate::process::tokio_command("git") .args(["branch", "-r", "--format=%(refname:short)"]) .current_dir(&path) .output(); let wt_fut = crate::process::tokio_command("git") .args(["worktree", "list", "--porcelain"]) .current_dir(&path) .output(); let (local_output, remote_output, wt_output) = tokio::join!(local_fut, remote_fut, wt_fut); let local_output = local_output.map_err(AppCommandError::io)?; if !local_output.status.success() { return Err(git_command_error("branch", &local_output.stderr)); } let local: Vec = String::from_utf8_lossy(&local_output.stdout) .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect(); let remote: Vec = match remote_output { Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout) .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty() && !l.contains("HEAD") && l.contains('/')) .collect(), _ => vec![], }; // Parse worktree entries, excluding the current worktree (path itself) let worktree_branches: Vec = match wt_output { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); let canonical_path = std::fs::canonicalize(&path).unwrap_or_else(|_| PathBuf::from(&path)); let mut branches = Vec::new(); let mut current_wt_path: Option = None; for line in stdout.lines() { if let Some(wt) = line.strip_prefix("worktree ") { current_wt_path = Some(wt.trim().to_string()); } else if let Some(b) = line.strip_prefix("branch refs/heads/") { if let Some(ref wt) = current_wt_path { let wt_canonical = std::fs::canonicalize(wt).unwrap_or_else(|_| PathBuf::from(wt)); if wt_canonical != canonical_path { branches.push(b.trim().to_string()); } } } else if line.is_empty() { current_wt_path = None; } } branches } _ => vec![], }; Ok(GitBranchList { local, remote, worktree_branches, }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_list_remotes(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["remote", "-v"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("remote -v", &output.stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let mut seen = HashSet::new(); let mut remotes = Vec::new(); for line in stdout.lines() { // Format: "name\turl (fetch|push)" if !line.ends_with("(fetch)") { continue; } let Some((name, rest)) = line.split_once('\t') else { continue; }; let url = rest.trim_end_matches("(fetch)").trim(); if seen.insert(name.to_string()) { remotes.push(GitRemote { name: name.to_string(), url: url.to_string(), }); } } Ok(remotes) } pub(crate) async fn git_fetch_remote_core( path: &str, name: &str, credentials: Option<&GitCredentials>, db: &AppDatabase, data_dir: &std::path::Path, ) -> Result { let mut cmd = crate::process::tokio_command("git"); cmd.args(["fetch", name]).current_dir(path); prepare_remote_git_cmd_with_remote(&mut cmd, path, Some(name), credentials, db, data_dir).await; let output = cmd .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(classify_remote_git_error("fetch", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_fetch_remote( path: String, name: String, credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result { let data_dir = app_handle.path().app_data_dir().map_err(|e| { AppCommandError::external_command("Failed to resolve app data dir", e.to_string()) })?; git_fetch_remote_core(&path, &name, credentials.as_ref(), &db, &data_dir).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_add_remote( path: String, name: String, url: String, ) -> Result<(), AppCommandError> { let output = crate::process::tokio_command("git") .args(["remote", "add", &name, &url]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("remote add", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_remove_remote(path: String, name: String) -> Result<(), AppCommandError> { let output = crate::process::tokio_command("git") .args(["remote", "remove", &name]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("remote remove", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_set_remote_url( path: String, name: String, url: String, ) -> Result<(), AppCommandError> { let output = crate::process::tokio_command("git") .args(["remote", "set-url", &name, &url]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("remote set-url", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_merge( path: String, branch_name: String, ) -> Result { // Count commits to be merged before performing merge let count_output = crate::process::tokio_command("git") .args(["rev-list", "--count", &format!("HEAD..{}", branch_name)]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; let merged_commits = if count_output.status.success() { String::from_utf8_lossy(&count_output.stdout) .trim() .parse::() .unwrap_or(0) } else { 0 }; let output = crate::process::tokio_command("git") .args(["merge", &branch_name]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { let conflicted_files = detect_conflicts(&path).await?; if !conflicted_files.is_empty() { return Ok(GitMergeResult { merged_commits, conflict: Some(GitConflictInfo { has_conflicts: true, conflicted_files, operation: "merge".to_string(), upstream_commit: None, }), }); } return Err(git_command_error("merge", &output.stderr)); } Ok(GitMergeResult { merged_commits, conflict: None, }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_rebase( path: String, branch_name: String, ) -> Result { let output = crate::process::tokio_command("git") .args(["rebase", &branch_name]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { let conflicted_files = detect_conflicts(&path).await?; if !conflicted_files.is_empty() { return Ok(GitRebaseResult { message: String::from_utf8_lossy(&output.stdout).trim().to_string(), conflict: Some(GitConflictInfo { has_conflicts: true, conflicted_files, operation: "rebase".to_string(), upstream_commit: None, }), }); } return Err(git_command_error("rebase", &output.stderr)); } Ok(GitRebaseResult { message: String::from_utf8_lossy(&output.stdout).trim().to_string(), conflict: None, }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_delete_branch( path: String, branch_name: String, force: bool, ) -> Result { let flag = if force { "-D" } else { "-d" }; let output = crate::process::tokio_command("git") .args(["branch", flag, &branch_name]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error(&format!("branch {flag}"), &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_list_conflicts(path: String) -> Result, AppCommandError> { detect_conflicts(&path).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_conflict_file_versions( path: String, file: String, ) -> Result { // :1: = base (common ancestor), :2: = ours (HEAD), :3: = theirs (incoming) let mut versions = Vec::with_capacity(3); for stage in ["1", "2", "3"] { let file_spec = format!(":{}:{}", stage, file); let output = crate::process::tokio_command("git") .args(["show", &file_spec]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { // File may not exist at this stage (e.g. newly added on one side) versions.push(String::new()); } else { let bytes = &output.stdout; if bytes.iter().take(2048).any(|b| *b == 0) { return Err( AppCommandError::invalid_input("Binary files are not supported") .with_detail(file_spec), ); } versions.push(String::from_utf8_lossy(bytes).to_string()); } } // Read the working tree file (contains conflict markers) let file_path = Path::new(&path).join(&file); let merged = std::fs::read_to_string(&file_path).unwrap_or_default(); Ok(GitConflictFileVersions { base: versions.remove(0), ours: versions.remove(0), theirs: versions.remove(0), merged, }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_resolve_conflict( path: String, file: String, content: String, ) -> Result<(), AppCommandError> { let file_path = Path::new(&path).join(&file); // Write resolved content std::fs::write(&file_path, content).map_err(|e| { AppCommandError::io_error(format!("Failed to write resolved file: {}", e)) })?; // Stage the resolved file let output = crate::process::tokio_command("git") .args(["add", &file]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("add", &output.stderr)); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_abort_operation( path: String, operation: String, ) -> Result<(), AppCommandError> { let args = match operation.as_str() { "merge" | "pull" => vec!["merge", "--abort"], "rebase" => vec!["rebase", "--abort"], _ => { return Err(AppCommandError::invalid_input(format!( "Unknown operation: {operation}" ))); } }; let output = crate::process::tokio_command("git") .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error( &format!("{} --abort", operation), &output.stderr, )); } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_continue_operation( path: String, operation: String, ) -> Result<(), AppCommandError> { let (program, args): (&str, Vec<&str>) = match operation.as_str() { "merge" | "pull" => ("git", vec!["commit", "--no-edit"]), "rebase" => ("git", vec!["rebase", "--continue"]), _ => { return Err(AppCommandError::invalid_input(format!( "Unknown operation: {operation}" ))); } }; let output = crate::process::tokio_command(program) .args(&args) .current_dir(&path) .env("GIT_EDITOR", "true") .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error( &format!("{} --continue", operation), &output.stderr, )); } Ok(()) } const WATCH_IGNORED_DIRS: &[&str] = &["__pycache__"]; const FILE_TREE_IGNORED_DIRS: &[&str] = &[".git", "__pycache__"]; /// Hard limit: refuse to open files larger than 50 MB in the text editor. const FILE_OPEN_HARD_LIMIT: usize = 50_000_000; /// Save limit: refuse to save content larger than 50 MB. const FILE_SAVE_HARD_LIMIT: usize = 50_000_000; const FILE_BASE64_DEFAULT_MAX_BYTES: usize = 20_000_000; const FILE_BASE64_MAX_BYTES: usize = 100_000_000; const FILE_IO_MAX_CONCURRENT_OPS: usize = 8; static FILE_IO_SEMAPHORE: LazyLock = LazyLock::new(|| Semaphore::new(FILE_IO_MAX_CONCURRENT_OPS)); static FILE_WATCHERS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); const FILE_WATCH_DEBOUNCE_MS: u64 = 150; const FILE_WATCH_MAX_BATCH_WINDOW_MS: u64 = 500; const FILE_WATCH_MAX_CHANGED_PATHS: usize = 2_000; fn to_git_literal_pathspec(path: &str) -> String { format!(":(literal){path}") } /// Remove surrounding quotes from a git output path. /// Git quotes paths containing non-ASCII or special characters, e.g. /// `"path/\344\270\255\346\226\207.txt"`. With `core.quotePath=false` /// the octal escapes are gone, but the quotes may still appear for paths /// with spaces, tabs, etc. fn unquote_git_path(path: &str) -> String { let trimmed = path.trim(); if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { trimmed[1..trimmed.len() - 1].to_string() } else { trimmed.to_string() } } fn normalize_slash_path(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } fn is_git_metadata_rel_path(path: &str) -> bool { path == ".git" || path.starts_with(".git/") } fn is_gitignore_rel_path(path: &str) -> bool { Path::new(path) .file_name() .map(|name| name.to_string_lossy() == ".gitignore") .unwrap_or(false) } fn is_codeg_edit_temp_path(path: &Path) -> bool { path.file_name() .map(|name| { let name = name.to_string_lossy(); name.starts_with(".codeg-edit-") && name.ends_with(".tmp") }) .unwrap_or(false) } fn git_check_ignored_paths( repo_path: &str, paths: &[String], ) -> Result, AppCommandError> { if paths.is_empty() { return Ok(HashSet::new()); } let mut child = crate::process::std_command("git") .args(["check-ignore", "--stdin", "-z"]) .current_dir(repo_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(AppCommandError::io)?; if let Some(mut stdin) = child.stdin.take() { for path in paths { stdin .write_all(path.as_bytes()) .map_err(AppCommandError::io)?; stdin.write_all(&[0]).map_err(AppCommandError::io)?; } } let output = child.wait_with_output().map_err(AppCommandError::io)?; // Exit code 1 means "no matches", which is expected. if !output.status.success() && output.status.code() != Some(1) { return Err(git_command_error("check-ignore", &output.stderr)); } let mut ignored = HashSet::new(); for raw in output.stdout.split(|byte| *byte == 0) { if raw.is_empty() { continue; } ignored.insert(String::from_utf8_lossy(raw).to_string()); } Ok(ignored) } fn should_refresh_git_status_for_paths(root_display: &str, changed_paths: &[String]) -> bool { if changed_paths.is_empty() { return true; } let mut candidates: Vec = Vec::new(); for path in changed_paths { if is_git_metadata_rel_path(path) || is_gitignore_rel_path(path) { return true; } candidates.push(path.clone()); } if candidates.is_empty() { return false; } let ignored = match git_check_ignored_paths(root_display, &candidates) { Ok(ignored) => ignored, // Fail safe: if detection fails, keep current behavior and refresh status. Err(_) => return true, }; candidates .iter() .any(|path| !ignored.contains(path.as_str())) } fn canonicalize_watch_root(root: &Path) -> Result<(PathBuf, String), AppCommandError> { let canonical = std::fs::canonicalize(root).map_err(|e| { AppCommandError::not_found("Unable to resolve workspace root").with_detail(e.to_string()) })?; let key = normalize_slash_path(&canonical); Ok((canonical, key)) } fn is_allowed_git_watch_path(relative: &Path) -> bool { let mut components = relative.components(); let Some(Component::Normal(first)) = components.next() else { return false; }; if first.to_string_lossy() != ".git" { return false; } let Some(Component::Normal(second)) = components.next() else { // Allow top-level .git events. return true; }; let second_name = second.to_string_lossy(); match second_name.as_ref() { "HEAD" | "index" | "packed-refs" | "FETCH_HEAD" | "ORIG_HEAD" | "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD" => true, "refs" => { let Some(Component::Normal(scope)) = components.next() else { return true; }; matches!( scope.to_string_lossy().as_ref(), "heads" | "remotes" | "stash" ) } "rebase-merge" | "rebase-apply" => true, _ => false, } } fn is_ignored_watch_path(path: &Path, root: &Path) -> bool { let Ok(relative) = path.strip_prefix(root) else { return false; }; if is_codeg_edit_temp_path(relative) { return true; } let mut components = relative.components(); if let Some(Component::Normal(first)) = components.next() { if first.to_string_lossy() == ".git" { return !is_allowed_git_watch_path(relative); } } relative.components().any(|component| { let Component::Normal(name) = component else { return false; }; let component_name = name.to_string_lossy(); WATCH_IGNORED_DIRS .iter() .any(|ignored| *ignored == component_name.as_ref()) }) } fn should_emit_watch_event(kind: &EventKind) -> bool { matches!( kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) ) } #[derive(Default)] struct WatchEventBatch { changed_paths: HashSet, has_create: bool, has_remove: bool, overflowed: bool, } impl WatchEventBatch { fn clear(&mut self) { self.changed_paths.clear(); self.has_create = false; self.has_remove = false; self.overflowed = false; } fn is_empty(&self) -> bool { !self.overflowed && self.changed_paths.is_empty() } fn kind(&self) -> &'static str { if self.has_remove { "remove" } else if self.has_create { "create" } else { "modify" } } fn ingest_event(&mut self, root_canonical: &Path, event: notify::Event) { if !should_emit_watch_event(&event.kind) { return; } if self.overflowed { return; } let mut has_relevant_path = false; for path in event.paths { if is_ignored_watch_path(&path, root_canonical) { continue; } let relative = if let Ok(relative) = path.strip_prefix(root_canonical) { normalize_slash_path(relative) } else { normalize_slash_path(&path) }; if relative.is_empty() { continue; } self.changed_paths.insert(relative); has_relevant_path = true; if self.changed_paths.len() > FILE_WATCH_MAX_CHANGED_PATHS { self.overflowed = true; self.changed_paths.clear(); break; } } if !has_relevant_path { return; } match event.kind { EventKind::Create(_) => self.has_create = true, EventKind::Remove(_) => self.has_remove = true, _ => {} } } fn emit(&self, emitter: &EventEmitter, root_display: &str) { if self.is_empty() { return; } let changed_paths = if self.overflowed { Vec::new() } else { let mut paths = self.changed_paths.iter().cloned().collect::>(); paths.sort(); paths }; let payload = FileTreeChangedEvent { root_path: root_display.to_string(), refresh_git_status: if self.overflowed { true } else { should_refresh_git_status_for_paths(root_display, &changed_paths) }, changed_paths, kind: self.kind().to_string(), full_reload: self.overflowed, }; crate::web::event_bridge::emit_event(emitter, "folder://file-tree-changed", payload); } } fn run_file_watch_event_loop( event_rx: mpsc::Receiver, emitter: EventEmitter, root_display: String, root_canonical: PathBuf, ) { let debounce = Duration::from_millis(FILE_WATCH_DEBOUNCE_MS); let max_batch_window = Duration::from_millis(FILE_WATCH_MAX_BATCH_WINDOW_MS); let mut batch = WatchEventBatch::default(); let mut batch_started_at: Option = None; loop { match event_rx.recv_timeout(debounce) { Ok(event) => { batch.ingest_event(&root_canonical, event); if !batch.is_empty() && batch_started_at.is_none() { batch_started_at = Some(Instant::now()); } while let Ok(next_event) = event_rx.try_recv() { batch.ingest_event(&root_canonical, next_event); if !batch.is_empty() && batch_started_at.is_none() { batch_started_at = Some(Instant::now()); } } let should_flush = if batch.overflowed { true } else { batch_started_at .map(|started| started.elapsed() >= max_batch_window) .unwrap_or(false) }; if should_flush { batch.emit(&emitter, &root_display); batch.clear(); batch_started_at = None; } } Err(mpsc::RecvTimeoutError::Timeout) => { if !batch.is_empty() { batch.emit(&emitter, &root_display); batch.clear(); batch_started_at = None; } } Err(mpsc::RecvTimeoutError::Disconnected) => { if !batch.is_empty() { batch.emit(&emitter, &root_display); } break; } } } } fn resolve_tree_path(root: &Path, rel_path: &str) -> Result { let rel = Path::new(rel_path); if rel.is_absolute() { return Err(AppCommandError::invalid_input("Path must be relative")); } for component in rel.components() { match component { Component::Normal(_) | Component::CurDir => {} Component::ParentDir => { return Err(AppCommandError::invalid_input("Path cannot contain '..'")); } Component::RootDir | Component::Prefix(_) => { return Err(AppCommandError::invalid_input("Invalid path component")); } } } Ok(root.join(rel)) } fn validate_new_name(new_name: &str) -> Result<&str, AppCommandError> { let trimmed = new_name.trim(); if trimmed.is_empty() { return Err(AppCommandError::invalid_input("New name cannot be empty")); } if trimmed == "." || trimmed == ".." { return Err(AppCommandError::invalid_input("Invalid file name")); } if trimmed.contains('/') || trimmed.contains('\\') { return Err(AppCommandError::invalid_input( "New name cannot contain path separators", )); } Ok(trimmed) } pub(crate) async fn start_file_tree_watch_core( emitter: EventEmitter, root_path: String, ) -> Result<(), AppCommandError> { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } let (root_canonical, key) = canonicalize_watch_root(&root)?; { let mut watchers = FILE_WATCHERS.lock().map_err(|_| { AppCommandError::task_execution_failed("Failed to lock file watcher registry") })?; if let Some(entry) = watchers.get_mut(&key) { entry.ref_count += 1; return Ok(()); } } let root_display_for_worker = root_path.clone(); let root_display_for_error = root_path.clone(); let root_canonical_for_worker = root_canonical.clone(); let emitter_for_worker = emitter; let (event_tx, event_rx) = mpsc::channel::(); let mut worker = Some(std::thread::spawn(move || { run_file_watch_event_loop( event_rx, emitter_for_worker, root_display_for_worker, root_canonical_for_worker, ) })); let mut watcher = Some( notify::recommended_watcher( move |result: Result| match result { Ok(event) => { let _ = event_tx.send(event); } Err(err) => { eprintln!( "[file-watch] failed event for {}: {}", root_display_for_error, err ); } }, ) .map_err(|e| { AppCommandError::io_error("Failed to create file watcher").with_detail(e.to_string()) })?, ); watcher .as_mut() .ok_or_else(|| AppCommandError::task_execution_failed("Failed to create file watcher"))? .watch(&root_canonical, RecursiveMode::Recursive) .map_err(|e| { AppCommandError::io_error("Failed to start file watcher").with_detail(e.to_string()) })?; let should_cleanup_new_watcher = { let mut watchers = FILE_WATCHERS.lock().map_err(|_| { AppCommandError::task_execution_failed("Failed to lock file watcher registry") })?; if let Some(entry) = watchers.get_mut(&key) { entry.ref_count += 1; true } else { watchers.insert( key, FileWatchEntry { root_canonical, root_display: root_path, watcher: watcher.take().ok_or_else(|| { AppCommandError::task_execution_failed( "Failed to initialize file watcher state", ) })?, worker: worker.take(), ref_count: 1, }, ); false } }; if !should_cleanup_new_watcher { return Ok(()); } drop(watcher.take()); if let Some(handle) = worker.take() { let _ = handle.join(); } Ok(()) } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn start_file_tree_watch( app: tauri::AppHandle, root_path: String, ) -> Result<(), AppCommandError> { let emitter = EventEmitter::Tauri(app); start_file_tree_watch_core(emitter, root_path).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn stop_file_tree_watch(root_path: String) -> Result<(), AppCommandError> { let root = PathBuf::from(&root_path); let key = canonicalize_watch_root(&root) .map(|(_, key)| key) .unwrap_or_else(|_| normalize_slash_path(&root)); let mut watchers = FILE_WATCHERS.lock().map_err(|_| { AppCommandError::task_execution_failed("Failed to lock file watcher registry") })?; let target_key = if watchers.contains_key(&key) { Some(key) } else { watchers.iter().find_map(|(candidate_key, entry)| { if entry.root_display == root_path { Some(candidate_key.clone()) } else { None } }) }; let Some(target_key) = target_key else { return Ok(()); }; if let Some(entry) = watchers.get_mut(&target_key) { if entry.ref_count > 1 { entry.ref_count -= 1; return Ok(()); } } let mut removed_entry = watchers.remove(&target_key); drop(watchers); if let Some(mut entry) = removed_entry.take() { let _ = entry.watcher.unwatch(&entry.root_canonical); drop(entry.watcher); if let Some(worker) = entry.worker.take() { let _ = worker.join(); } } Ok(()) } fn file_mtime_ms(metadata: &std::fs::Metadata) -> Option { let modified = metadata.modified().ok()?; let elapsed = modified.duration_since(UNIX_EPOCH).ok()?; let millis = elapsed.as_millis(); if millis > i64::MAX as u128 { return Some(i64::MAX); } Some(millis as i64) } fn detect_line_ending(content: &[u8]) -> String { let mut has_lf = false; let mut has_crlf = false; for index in 0..content.len() { if content[index] != b'\n' { continue; } if index > 0 && content[index - 1] == b'\r' { has_crlf = true; } else { has_lf = true; } if has_lf && has_crlf { return "mixed".to_string(); } } if has_crlf { "crlf".to_string() } else if has_lf { "lf".to_string() } else { "none".to_string() } } fn compute_etag(content: &[u8], metadata: &std::fs::Metadata) -> String { let mut hasher = DefaultHasher::new(); content.hash(&mut hasher); metadata.len().hash(&mut hasher); if let Some(mtime_ms) = file_mtime_ms(metadata) { mtime_ms.hash(&mut hasher); } format!("{:016x}", hasher.finish()) } fn ensure_path_in_workspace(root: &Path, target: &Path) -> Result<(), AppCommandError> { let canonical_root = std::fs::canonicalize(root).map_err(AppCommandError::io)?; let canonical_target = std::fs::canonicalize(target).map_err(AppCommandError::io)?; if !canonical_target.starts_with(&canonical_root) { return Err(AppCommandError::invalid_input( "Path is outside workspace root", )); } Ok(()) } fn read_text_full(target: &Path, hard_limit: usize) -> Result { let metadata = std::fs::metadata(target).map_err(AppCommandError::io)?; if metadata.len() > hard_limit as u64 { return Err( AppCommandError::invalid_input("File is too large to open in editor") .with_detail(format!( "size={}, limit={}", metadata.len(), hard_limit )), ); } let bytes = std::fs::read(target).map_err(AppCommandError::io)?; if bytes.iter().take(2_048).any(|b| *b == 0) { return Err(AppCommandError::invalid_input( "Binary files are not supported in preview", )); } Ok(String::from_utf8_lossy(&bytes).to_string()) } fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), AppCommandError> { let parent = path.parent().ok_or_else(|| { AppCommandError::invalid_input("Cannot determine parent directory for target file") .with_detail(path.display().to_string()) })?; if !parent.exists() { return Err( AppCommandError::not_found("Parent directory does not exist") .with_detail(parent.display().to_string()), ); } let temp_path = parent.join(format!( ".codeg-edit-{}.{}.tmp", std::process::id(), uuid::Uuid::new_v4().simple() )); let existing_permissions = std::fs::metadata(path).ok().map(|m| m.permissions()); let write_result = (|| -> Result<(), AppCommandError> { let mut temp = OpenOptions::new() .create_new(true) .write(true) .open(&temp_path) .map_err(AppCommandError::io)?; temp.write_all(bytes).map_err(AppCommandError::io)?; temp.sync_all().map_err(AppCommandError::io)?; if let Some(permissions) = existing_permissions { std::fs::set_permissions(&temp_path, permissions).map_err(AppCommandError::io)?; } replace_file(&temp_path, path)?; sync_directory(parent)?; Ok(()) })(); if write_result.is_err() { let _ = std::fs::remove_file(&temp_path); } write_result } #[cfg(unix)] fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), AppCommandError> { std::fs::rename(temp_path, target_path).map_err(AppCommandError::io) } #[cfg(target_os = "windows")] fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), AppCommandError> { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::{ MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, }; fn to_wide(path: &Path) -> Vec { path.as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect() } let src = to_wide(temp_path); let dst = to_wide(target_path); // SAFETY: pointers are valid and UTF-16 null-terminated for the duration of the call. let ok = unsafe { MoveFileExW( src.as_ptr(), dst.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, ) }; if ok == 0 { return Err( AppCommandError::io_error("Failed to atomically replace file") .with_detail(std::io::Error::last_os_error().to_string()), ); } Ok(()) } #[cfg(not(any(unix, target_os = "windows")))] fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), AppCommandError> { std::fs::rename(temp_path, target_path).map_err(AppCommandError::io) } #[cfg(unix)] fn sync_directory(path: &Path) -> Result<(), AppCommandError> { let dir = std::fs::File::open(path).map_err(AppCommandError::io)?; dir.sync_all().map_err(AppCommandError::io) } #[cfg(not(unix))] fn sync_directory(_path: &Path) -> Result<(), AppCommandError> { Ok(()) } async fn run_file_io(f: F) -> Result where T: Send + 'static, F: FnOnce() -> Result + Send + 'static, { let _permit = FILE_IO_SEMAPHORE .acquire() .await .map_err(|_| AppCommandError::task_execution_failed("File I/O runtime is unavailable"))?; tokio::task::spawn_blocking(f).await.map_err(|e| { AppCommandError::task_execution_failed("File I/O task failed").with_detail(e.to_string()) })? } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_file_tree( path: String, max_depth: Option, ) -> Result, AppCommandError> { let root = PathBuf::from(&path); let depth = max_depth.unwrap_or(usize::MAX); // Collect all entries, skipping ignored directories let mut dir_children: HashMap> = HashMap::new(); let mut dir_order: Vec = Vec::new(); let mut dir_paths_by_rel: HashMap = HashMap::new(); for entry in WalkDir::new(&root) .max_depth(depth) .sort_by_file_name() .into_iter() .filter_entry(|e| { let name = e.file_name().to_string_lossy(); if e.file_type().is_dir() { !FILE_TREE_IGNORED_DIRS.contains(&name.as_ref()) } else { name != ".DS_Store" } }) { let entry = entry.map_err(|e| { AppCommandError::io_error("Failed to walk file tree").with_detail(e.to_string()) })?; let entry_path = entry.path().to_path_buf(); // Skip the root itself if entry_path == root { dir_children.entry(root.clone()).or_default(); dir_order.push(root.clone()); continue; } let parent = entry_path.parent().unwrap_or(&root).to_path_buf(); let name = entry.file_name().to_string_lossy().to_string(); let rel_path = entry_path .strip_prefix(&root) .unwrap_or(&entry_path) .to_string_lossy() .replace('\\', "/"); if entry.file_type().is_dir() { dir_paths_by_rel.insert(rel_path.clone(), entry_path.clone()); dir_children.entry(entry_path.clone()).or_default(); dir_order.push(entry_path); // Add a placeholder Dir node to parent (children filled later) dir_children .entry(parent) .or_default() .push(FileTreeNode::Dir { name, path: rel_path, children: vec![], }); } else { dir_children .entry(parent) .or_default() .push(FileTreeNode::File { name, path: rel_path, }); } } // Build tree bottom-up: process dirs in reverse order so children are ready for dir_path in dir_order.iter().rev() { let children = dir_children.remove(dir_path).unwrap_or_default(); // Sort: dirs first, then files, alphabetically within each group let mut dirs: Vec = Vec::new(); let mut files: Vec = Vec::new(); for child in children { match &child { FileTreeNode::Dir { .. } => dirs.push(child), FileTreeNode::File { .. } => files.push(child), } } dirs.sort_by(|a, b| { let a_name = match a { FileTreeNode::Dir { name, .. } => name, _ => unreachable!(), }; let b_name = match b { FileTreeNode::Dir { name, .. } => name, _ => unreachable!(), }; a_name.to_lowercase().cmp(&b_name.to_lowercase()) }); files.sort_by(|a, b| { let a_name = match a { FileTreeNode::File { name, .. } => name, _ => unreachable!(), }; let b_name = match b { FileTreeNode::File { name, .. } => name, _ => unreachable!(), }; a_name.to_lowercase().cmp(&b_name.to_lowercase()) }); let mut sorted: Vec = Vec::with_capacity(dirs.len() + files.len()); // Fill dir children from the map for d in dirs { if let FileTreeNode::Dir { name, path: rel_path, .. } = d { let full_path = dir_paths_by_rel .get(&rel_path) .cloned() .unwrap_or_else(|| root.join(Path::new(&rel_path))); let sub_children = dir_children.remove(&full_path).unwrap_or_default(); sorted.push(FileTreeNode::Dir { name, path: rel_path, children: sub_children, }); } } sorted.extend(files); dir_children.insert(dir_path.clone(), sorted); } Ok(dir_children.remove(&root).unwrap_or_default()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn read_file_base64( path: String, max_bytes: Option, ) -> Result { let trimmed = path.trim(); if trimmed.is_empty() { return Err(AppCommandError::invalid_input("Path cannot be empty")); } let target = PathBuf::from(trimmed); if !target.exists() { return Err(AppCommandError::not_found("File does not exist")); } if !target.is_file() { return Err(AppCommandError::invalid_input("Path is not a file")); } let limit = max_bytes .unwrap_or(FILE_BASE64_DEFAULT_MAX_BYTES) .clamp(4_096, FILE_BASE64_MAX_BYTES); run_file_io(move || { let metadata = std::fs::metadata(&target).map_err(AppCommandError::io)?; if metadata.len() > limit as u64 { return Err( AppCommandError::invalid_input("File is too large to attach") .with_detail(format!("max_bytes={limit}")), ); } let bytes = std::fs::read(&target).map_err(AppCommandError::io)?; if bytes.len() > limit { return Err( AppCommandError::invalid_input("File is too large to attach") .with_detail(format!("max_bytes={limit}")), ); } Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) }) .await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn read_file_preview( root_path: String, path: String, ) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { return Err(AppCommandError::not_found("File does not exist")); } if !target.is_file() { return Err(AppCommandError::invalid_input("Path is not a file")); } let path_for_response = path.clone(); run_file_io(move || { ensure_path_in_workspace(&root, &target)?; let content = read_text_full(&target, FILE_OPEN_HARD_LIMIT)?; Ok(FilePreviewContent { path: path_for_response, content, }) }) .await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn read_file_for_edit( root_path: String, path: String, ) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { return Err(AppCommandError::not_found("File does not exist")); } if !target.is_file() { return Err(AppCommandError::invalid_input("Path is not a file")); } let path_for_response = path.clone(); run_file_io(move || { ensure_path_in_workspace(&root, &target)?; let metadata = std::fs::metadata(&target).map_err(AppCommandError::io)?; let content = read_text_full(&target, FILE_OPEN_HARD_LIMIT)?; let readonly = metadata.permissions().readonly(); let mtime_ms = file_mtime_ms(&metadata); let etag = compute_etag(content.as_bytes(), &metadata); let line_ending = detect_line_ending(content.as_bytes()); Ok(FileEditContent { path: path_for_response, content, etag, mtime_ms, readonly, line_ending, }) }) .await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn save_file_content( root_path: String, path: String, content: String, expected_etag: Option, ) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } if content.len() > FILE_SAVE_HARD_LIMIT { return Err( AppCommandError::invalid_input("File is too large to save in editor") .with_detail(format!("max_bytes={FILE_SAVE_HARD_LIMIT}")), ); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { return Err(AppCommandError::not_found("File does not exist")); } if !target.is_file() { return Err(AppCommandError::invalid_input("Path is not a file")); } let path_for_response = path.clone(); run_file_io(move || { ensure_path_in_workspace(&root, &target)?; let link_meta = std::fs::symlink_metadata(&target).map_err(AppCommandError::io)?; if link_meta.file_type().is_symlink() { return Err(AppCommandError::invalid_input( "Saving symlink targets is not supported", )); } let before_meta = std::fs::metadata(&target).map_err(AppCommandError::io)?; if before_meta.permissions().readonly() { return Err(AppCommandError::permission_denied("File is read-only")); } let current_bytes = std::fs::read(&target).map_err(AppCommandError::io)?; if current_bytes.iter().take(2_048).any(|b| *b == 0) { return Err(AppCommandError::invalid_input( "Binary files are not supported in editor", )); } let current_etag = compute_etag(¤t_bytes, &before_meta); if let Some(expected) = expected_etag { if expected != current_etag { return Err(AppCommandError::invalid_input( "File has changed on disk. Reload the file before saving.", )); } } atomic_write_text(&target, content.as_bytes())?; let after_meta = std::fs::metadata(&target).map_err(AppCommandError::io)?; let etag = compute_etag(content.as_bytes(), &after_meta); let mtime_ms = file_mtime_ms(&after_meta); let readonly = after_meta.permissions().readonly(); let line_ending = detect_line_ending(content.as_bytes()); Ok(FileSaveResult { path: path_for_response, etag, mtime_ms, readonly, line_ending, }) }) .await } fn build_local_copy_file_name(original_name: &str, attempt: usize) -> String { let original = Path::new(original_name); let stem = original .file_stem() .and_then(|value| value.to_str()) .filter(|value| !value.is_empty()) .unwrap_or(original_name); let extension = original .extension() .and_then(|value| value.to_str()) .filter(|value| !value.is_empty()); let suffix = if attempt <= 1 { ".local".to_string() } else { format!(".local.{}", attempt) }; match extension { Some(ext) => format!("{stem}{suffix}.{ext}"), None => format!("{stem}{suffix}"), } } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn save_file_copy( root_path: String, path: String, content: String, ) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } if content.len() > FILE_SAVE_HARD_LIMIT { return Err( AppCommandError::invalid_input("File is too large to save in editor") .with_detail(format!("max_bytes={FILE_SAVE_HARD_LIMIT}")), ); } let source = resolve_tree_path(&root, &path)?; if !source.exists() { return Err(AppCommandError::not_found("File does not exist")); } if !source.is_file() { return Err(AppCommandError::invalid_input("Path is not a file")); } run_file_io(move || { ensure_path_in_workspace(&root, &source)?; let source_meta = std::fs::symlink_metadata(&source).map_err(AppCommandError::io)?; if source_meta.file_type().is_symlink() { return Err(AppCommandError::invalid_input( "Saving symlink targets is not supported", )); } let parent = source .parent() .ok_or_else(|| { AppCommandError::invalid_input("Cannot determine parent directory for source file") })? .to_path_buf(); ensure_path_in_workspace(&root, &parent)?; let source_name = source .file_name() .map(|value| value.to_string_lossy().to_string()) .ok_or_else(|| AppCommandError::invalid_input("Cannot determine source file name"))?; let mut created_path: Option = None; for attempt in 1..=9_999 { let candidate_name = build_local_copy_file_name(&source_name, attempt); let candidate_path = parent.join(candidate_name); if candidate_path.exists() { continue; } created_path = Some(candidate_path); break; } let created_path = created_path.ok_or_else(|| { AppCommandError::already_exists( "Unable to create copy file: too many existing local copies", ) })?; atomic_write_text(&created_path, content.as_bytes())?; let metadata = std::fs::metadata(&created_path).map_err(AppCommandError::io)?; let etag = compute_etag(content.as_bytes(), &metadata); let mtime_ms = file_mtime_ms(&metadata); let readonly = metadata.permissions().readonly(); let line_ending = detect_line_ending(content.as_bytes()); let rel_path = created_path .strip_prefix(&root) .map_err(|e| { AppCommandError::invalid_input("Failed to compute relative path for copy") .with_detail(e.to_string()) })? .to_string_lossy() .replace('\\', "/"); Ok(FileSaveResult { path: rel_path, etag, mtime_ms, readonly, line_ending, }) }) .await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn rename_file_tree_entry( root_path: String, path: String, new_name: String, ) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { return Err(AppCommandError::not_found("Target file does not exist")); } if target == root { return Err(AppCommandError::invalid_input( "Cannot rename workspace root", )); } let parent = target .parent() .ok_or_else(|| AppCommandError::invalid_input("Cannot rename path without parent"))?; let validated_name = validate_new_name(&new_name)?; let next_path = parent.join(validated_name); if next_path == target { return Ok(path); } if next_path.exists() { return Err(AppCommandError::already_exists( "A file with this name already exists", )); } std::fs::rename(&target, &next_path).map_err(AppCommandError::io)?; let rel = next_path .strip_prefix(&root) .map_err(|e| { AppCommandError::invalid_input("Failed to compute relative path") .with_detail(e.to_string()) })? .to_string_lossy() .to_string(); Ok(rel) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn delete_file_tree_entry( root_path: String, path: String, ) -> Result<(), AppCommandError> { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } let target = resolve_tree_path(&root, &path)?; if !target.exists() { return Err(AppCommandError::not_found("Target file does not exist")); } if target == root { return Err(AppCommandError::invalid_input( "Cannot delete workspace root", )); } let meta = std::fs::symlink_metadata(&target).map_err(AppCommandError::io)?; if meta.is_dir() { std::fs::remove_dir_all(&target).map_err(AppCommandError::io)?; } else { std::fs::remove_file(&target).map_err(AppCommandError::io)?; } Ok(()) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn create_file_tree_entry( root_path: String, path: String, name: String, kind: String, ) -> Result { let root = PathBuf::from(&root_path); if !root.exists() || !root.is_dir() { return Err(AppCommandError::not_found("Folder does not exist")); } let validated_name = validate_new_name(&name)?; let parent_dir = if path.is_empty() { root.clone() } else { let resolved = resolve_tree_path(&root, &path)?; if !resolved.exists() { return Err(AppCommandError::not_found("Parent path does not exist")); } if resolved.is_file() { resolved .parent() .map(|p| p.to_path_buf()) .ok_or_else(|| AppCommandError::invalid_input("Cannot determine parent directory"))? } else { resolved } }; let target = parent_dir.join(validated_name); if target.exists() { return Err(AppCommandError::already_exists( "A file or directory with this name already exists", )); } match kind.as_str() { "file" => { std::fs::File::create(&target).map_err(AppCommandError::io)?; } "dir" => { std::fs::create_dir(&target).map_err(AppCommandError::io)?; } _ => { return Err(AppCommandError::invalid_input( "Kind must be 'file' or 'dir'", )); } } let rel = target .strip_prefix(&root) .map_err(|e| { AppCommandError::invalid_input("Failed to compute relative path") .with_detail(e.to_string()) })? .to_string_lossy() .to_string(); Ok(rel) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_log( path: String, limit: Option, branch: Option, remote: Option, ) -> Result { const COMMIT_META_PREFIX: &str = "__COMMIT__\0"; const MESSAGE_END_MARKER: &str = "__COMMIT_MESSAGE_END__"; let limit_str = format!("-{}", limit.unwrap_or(100)); let mut args = vec![ "log".to_string(), limit_str, format!( "--format=__COMMIT__%x00%h%x00%H%x00%an%x00%aI%n%B%n{MESSAGE_END_MARKER}" ), "--raw".to_string(), "--numstat".to_string(), "--no-renames".to_string(), ]; if let Some(ref b) = branch { args.push(b.clone()); } let output = crate::process::tokio_command("git") .args(["-c", "core.quotePath=false"]) .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { // Empty repo (no commits yet) — return empty list instead of error let stderr_str = String::from_utf8_lossy(&output.stderr); if stderr_str.contains("does not have any commits yet") || stderr_str.contains("unknown revision or path not in the working tree") { return Ok(GitLogResult { entries: Vec::new(), has_upstream: false, }); } return Err(git_command_error("log", &output.stderr)); } let mut entries = Vec::::new(); let mut current: Option = None; let mut reading_message = false; for line in String::from_utf8_lossy(&output.stdout).lines() { if let Some(meta) = line.strip_prefix(COMMIT_META_PREFIX) { if let Some(entry) = current.take() { entries.push(entry.finish()); } let parts: Vec<&str> = meta.splitn(4, '\0').collect(); if parts.len() == 4 { current = Some(GitLogEntryBuilder::new(parts)); reading_message = true; } else { reading_message = false; } continue; } let Some(entry) = current.as_mut() else { continue; }; if reading_message { if line == MESSAGE_END_MARKER { reading_message = false; entry.finalize_message(); } else { entry.push_message_line(line); } continue; } if line.is_empty() { continue; } if line.starts_with(':') { if let Some((status, file_path)) = parse_raw_file_line(line) { let file = entry.get_or_insert_file(file_path); file.status = status; } continue; } if let Some((additions, deletions, file_path)) = parse_numstat_file_line(line) { let file = entry.get_or_insert_file(file_path); file.additions = additions; file.deletions = deletions; } } if let Some(entry) = current { entries.push(entry.finish()); } let log_limit = limit.unwrap_or(100); let (unpushed_hashes, has_upstream) = get_unpushed_hashes(&path, log_limit, remote.as_deref()) .await .unwrap_or((None, false)); for entry in entries.iter_mut() { entry.pushed = unpushed_hashes .as_ref() .map(|hashes| !hashes.contains(&entry.full_hash)); } Ok(GitLogResult { entries, has_upstream, }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_commit_branches( path: String, commit: String, ) -> Result, AppCommandError> { let contains_arg = format!("--contains={commit}"); let output = crate::process::tokio_command("git") .args([ "for-each-ref", &contains_arg, "--format=%(refname:short)", "refs/heads", "refs/remotes", ]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { return Err(git_command_error("for-each-ref", &output.stderr)); } let mut seen = HashSet::new(); let mut branches = Vec::new(); for line in String::from_utf8_lossy(&output.stdout).lines() { let branch = line.trim(); if branch.is_empty() || branch.ends_with("/HEAD") { continue; } if seen.insert(branch.to_string()) { branches.push(branch.to_string()); } } branches.sort(); Ok(branches) } struct GitLogEntryBuilder { hash: String, full_hash: String, author: String, date: String, message: String, files: Vec, index_by_path: HashMap, } impl GitLogEntryBuilder { fn new(parts: Vec<&str>) -> Self { Self { hash: parts[0].to_string(), full_hash: parts[1].to_string(), author: parts[2].to_string(), date: parts[3].to_string(), message: String::new(), files: Vec::new(), index_by_path: HashMap::new(), } } fn push_message_line(&mut self, line: &str) { if !self.message.is_empty() { self.message.push('\n'); } self.message.push_str(line); } fn finalize_message(&mut self) { self.message = self.message.trim_end_matches('\n').to_string(); } fn get_or_insert_file(&mut self, path: String) -> &mut GitLogFileChange { let index = if let Some(index) = self.index_by_path.get(&path) { *index } else { self.files.push(GitLogFileChange { path: path.clone(), status: "M".to_string(), additions: 0, deletions: 0, }); let index = self.files.len() - 1; self.index_by_path.insert(path, index); index }; &mut self.files[index] } fn finish(self) -> GitLogEntry { GitLogEntry { hash: self.hash, full_hash: self.full_hash, author: self.author, date: self.date, message: self.message, files: self.files, pushed: None, } } } fn parse_raw_file_line(line: &str) -> Option<(String, String)> { let mut parts = line.split('\t'); let meta = parts.next()?; let file_path = unquote_git_path(parts.next()?); let status = meta .split_whitespace() .last() .and_then(|v| v.chars().next()) .unwrap_or('M') .to_string(); Some((status, file_path)) } fn parse_numstat_file_line(line: &str) -> Option<(u32, u32, String)> { let mut parts = line.splitn(3, '\t'); let additions = parse_numstat_count(parts.next()?); let deletions = parse_numstat_count(parts.next()?); let file_path = unquote_git_path(parts.next()?); Some((additions, deletions, file_path)) } fn parse_numstat_count(value: &str) -> u32 { if value == "-" { return 0; } value.parse::().unwrap_or(0) } /// Returns (unpushed_hashes, has_upstream). async fn get_unpushed_hashes( path: &str, limit: u32, remote_override: Option<&str>, ) -> Result<(Option>, bool), AppCommandError> { let limit_arg = format!("-{}", limit); let upstream_output = crate::process::tokio_command("git") .args([ "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", ]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; let has_upstream = upstream_output.status.success() && !String::from_utf8_lossy(&upstream_output.stdout) .trim() .is_empty(); // Determine the comparison target for unpushed commits. // We compare against / specifically rather than all remote // branches, so that commits shared with other remote branches still appear. let rev_list_output = if has_upstream && remote_override.is_none() { // Fast path: branch has an upstream tracking ref, use it directly let upstream = String::from_utf8_lossy(&upstream_output.stdout) .trim() .to_string(); let range = format!("{upstream}..HEAD"); crate::process::tokio_command("git") .args(["rev-list", &limit_arg, &range]) .current_dir(path) .output() .await .map_err(AppCommandError::io)? } else { // Either remote_override is specified or no upstream exists. // Resolve the current branch and the target remote. let branch_output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(path) .output() .await .map_err(AppCommandError::io)?; if !branch_output.status.success() { return Ok((None, has_upstream)); } let branch = String::from_utf8_lossy(&branch_output.stdout) .trim() .to_string(); if branch.is_empty() || branch == "HEAD" { return Ok((None, has_upstream)); } let remote = if let Some(r) = remote_override { r.to_string() } else { let remote_key = format!("branch.{}.remote", branch); let remote_output = crate::process::tokio_command("git") .args(["config", "--get", &remote_key]) .current_dir(path) .output() .await; remote_output .ok() .filter(|output| output.status.success()) .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "origin".to_string()) }; // Try comparing against / directly let remote_branch_ref = format!("refs/remotes/{}/{}", remote, branch); let verify_output = crate::process::tokio_command("git") .args(["rev-parse", "--verify", "--quiet", &remote_branch_ref]) .current_dir(path) .output() .await; let remote_branch_exists = verify_output .is_ok_and(|o| o.status.success()); if remote_branch_exists { let range = format!("{}/{}..HEAD", remote, branch); crate::process::tokio_command("git") .args(["rev-list", &limit_arg, &range]) .current_dir(path) .output() .await .map_err(AppCommandError::io)? } else { // Branch doesn't exist on remote yet (new branch). // Try merge-base with the remote's default branch to show // the meaningful divergence point. let remote_head = format!("{}/HEAD", remote); let mb_output = crate::process::tokio_command("git") .args(["merge-base", "HEAD", &remote_head]) .current_dir(path) .output() .await; let merge_base = mb_output .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()); if let Some(base) = merge_base { let range = format!("{}..HEAD", base); crate::process::tokio_command("git") .args(["rev-list", &limit_arg, &range]) .current_dir(path) .output() .await .map_err(AppCommandError::io)? } else { // Last resort: compare against all branches on the remote let remote_arg = format!("--remotes={}", remote); crate::process::tokio_command("git") .args(["rev-list", &limit_arg, "HEAD", "--not", &remote_arg]) .current_dir(path) .output() .await .map_err(AppCommandError::io)? } } }; if !rev_list_output.status.success() { return Ok((None, has_upstream)); } let hashes = String::from_utf8_lossy(&rev_list_output.stdout) .lines() .filter(|line| !line.is_empty()) .map(|line| line.to_string()) .collect::>(); Ok((Some(hashes), has_upstream)) }