From cc79d62b27e9f3cb59606aac3af1f6b4a5378286 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 18 Apr 2026 23:07:13 +0800 Subject: [PATCH] fix(git): surface non-git-repo as a typed error and align all panels via workspace state Consolidate `.git` presence detection into a shared `git_repo` module used by both the workspace state watcher and the command preflight helper, replacing duplicated local definitions. Introduce `AppErrorCode::NotAGitRepository` (HTTP 422) and preflight eleven frontend-callable git commands (log, status, list-branches, diff, diff-with-branch, show-diff, show-file, push-info, list-remotes, list-all-branches, commit-branches) so non-git folders short-circuit with a structured error instead of leaking locale-dependent git stderr. Frontend `isNotAGitRepoError` checks the error code first and falls back to a multi-language regex list centralized in `src/i18n/git-error-patterns.ts`, covering the nine languages git actually translates into. Wire the git log panel to `workspaceState.isGitRepo` rather than a local cached flag, so running `git init` or deleting `.git` externally propagates through the watcher and refreshes the panel automatically. --- src-tauri/src/app_error.rs | 5 +++ src-tauri/src/commands/folders.rs | 24 +++++++++++ src-tauri/src/git_repo.rs | 40 +++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/error.rs | 3 +- src-tauri/src/workspace_state/mod.rs | 5 +-- .../layout/aux-panel-git-log-tab.tsx | 30 ++++++++++---- src/i18n/git-error-patterns.ts | 29 ++++++++++++++ src/lib/app-error.ts | 11 ++++- 9 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/git_repo.rs create mode 100644 src/i18n/git-error-patterns.ts diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs index 7671ac7..4cd0c7c 100644 --- a/src-tauri/src/app_error.rs +++ b/src-tauri/src/app_error.rs @@ -9,6 +9,7 @@ pub enum AppErrorCode { ConfigurationMissing, ConfigurationInvalid, NotFound, + NotAGitRepository, AlreadyExists, PermissionDenied, DependencyMissing, @@ -65,6 +66,10 @@ impl AppCommandError { Self::new(AppErrorCode::NotFound, message) } + pub fn not_a_git_repository(message: impl Into) -> Self { + Self::new(AppErrorCode::NotAGitRepository, message) + } + pub fn already_exists(message: impl Into) -> Self { Self::new(AppErrorCode::AlreadyExists, message) } diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 6b5680a..8fe51b1 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -307,6 +307,8 @@ fn git_command_error(operation: &str, stderr: &[u8]) -> AppCommandError { AppCommandError::external_command(format!("git {operation} failed"), stderr) } +use crate::git_repo::ensure_git_repo; + async fn detect_conflicts(path: &str) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["-c", "core.quotePath=false"]) @@ -902,6 +904,8 @@ pub async fn git_fetch( #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_push_info(path: String) -> Result { + ensure_git_repo(&path)?; + // Get current branch name let branch_output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) @@ -1186,6 +1190,8 @@ pub async fn git_reset( #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_list_branches(path: String) -> Result, AppCommandError> { + ensure_git_repo(&path)?; + let output = crate::process::tokio_command("git") .args(["branch", "--format=%(refname:short)"]) .current_dir(&path) @@ -1401,6 +1407,8 @@ pub async fn git_status( path: String, show_all_untracked: Option, ) -> Result, AppCommandError> { + ensure_git_repo(&path)?; + let untracked_mode = if show_all_untracked.unwrap_or(false) { "-uall" } else { @@ -1446,6 +1454,8 @@ pub async fn git_is_tracked(path: String, file: String) -> Result) -> Result { + ensure_git_repo(&path)?; + 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 { @@ -1485,6 +1495,8 @@ pub async fn git_diff_with_branch( branch: String, file: Option, ) -> Result { + ensure_git_repo(&path)?; + let target_branch = branch.trim(); if target_branch.is_empty() { return Err(AppCommandError::invalid_input( @@ -1527,6 +1539,8 @@ pub async fn git_show_diff( commit: String, file: Option, ) -> Result { + ensure_git_repo(&path)?; + let literal_file = file.as_deref().map(to_git_literal_pathspec); let mut args = vec![ "show".to_string(), @@ -1559,6 +1573,8 @@ pub async fn git_show_file( file: String, ref_name: Option, ) -> Result { + ensure_git_repo(&path)?; + let git_ref = ref_name.unwrap_or_else(|| "HEAD".to_string()); let file_spec = format!("{}:{}", git_ref, file); @@ -1783,6 +1799,8 @@ pub async fn git_add_files(path: String, files: Vec) -> Result<(), AppCo #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn git_list_all_branches(path: String) -> Result { + ensure_git_repo(&path)?; + let local_fut = crate::process::tokio_command("git") .args(["branch", "--format=%(refname:short)"]) .current_dir(&path) @@ -1857,6 +1875,8 @@ pub async fn git_list_all_branches(path: String) -> Result Result, AppCommandError> { + ensure_git_repo(&path)?; + let output = crate::process::tokio_command("git") .args(["remote", "-v"]) .current_dir(&path) @@ -3201,6 +3221,8 @@ pub async fn git_log( branch: Option, remote: Option, ) -> Result { + ensure_git_repo(&path)?; + const COMMIT_META_PREFIX: &str = "__COMMIT__\0"; const MESSAGE_END_MARKER: &str = "__COMMIT_MESSAGE_END__"; @@ -3317,6 +3339,8 @@ pub async fn git_commit_branches( path: String, commit: String, ) -> Result, AppCommandError> { + ensure_git_repo(&path)?; + let contains_arg = format!("--contains={commit}"); let output = crate::process::tokio_command("git") .args([ diff --git a/src-tauri/src/git_repo.rs b/src-tauri/src/git_repo.rs new file mode 100644 index 0000000..34dd1c2 --- /dev/null +++ b/src-tauri/src/git_repo.rs @@ -0,0 +1,40 @@ +//! Single source of truth for "is this path a git repository?" detection. +//! +//! The check is deliberately strict: the exact path must contain a `.git` +//! entry (directory for regular repos, file for linked worktrees and +//! submodules). We do **not** walk up to ancestors. +//! +//! Rationale: codeg scopes every workspace-facing feature (file tree +//! watcher, git changes panel, log panel) to the directory the user opens. +//! If one code path walks up and another doesn't, the UI falls into a +//! "schizophrenic" state where some panels see a repo and others don't. +//! Keeping the primitive strict forces every consumer onto the same +//! interpretation. +//! +//! Bare repositories are intentionally not supported — they have no working +//! tree, which makes them an unusual target for a workspace-oriented editor. + +use std::path::Path; + +use crate::app_error::AppCommandError; + +/// Returns true when `path` is the root of a git working tree. +/// +/// `.git` may be a directory (normal repo) or a file (worktree/submodule +/// pointer). `Path::exists` treats both as present. +pub fn is_git_repo(path: &Path) -> bool { + path.join(".git").exists() +} + +/// Preflight guard for git commands. Short-circuits with a typed error code +/// when the target path is not a git working tree, so callers avoid locale- +/// dependent stderr parsing for the most common "wrong folder" failure. +pub fn ensure_git_repo(path: &str) -> Result<(), AppCommandError> { + if is_git_repo(Path::new(path)) { + Ok(()) + } else { + Err(AppCommandError::not_a_git_repository(format!( + "Not a Git repository: {path}" + ))) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 411bf4f..ba69a09 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod chat_channel; pub mod commands; pub mod db; pub mod git_credential; +pub mod git_repo; pub mod keyring_store; mod models; mod network; diff --git a/src-tauri/src/web/handlers/error.rs b/src-tauri/src/web/handlers/error.rs index a6436b1..f2705e8 100644 --- a/src-tauri/src/web/handlers/error.rs +++ b/src-tauri/src/web/handlers/error.rs @@ -16,7 +16,8 @@ impl IntoResponse for AppCommandError { AppErrorCode::AuthenticationFailed => StatusCode::UNAUTHORIZED, AppErrorCode::ConfigurationMissing | AppErrorCode::ConfigurationInvalid - | AppErrorCode::DependencyMissing => StatusCode::UNPROCESSABLE_ENTITY, + | AppErrorCode::DependencyMissing + | AppErrorCode::NotAGitRepository => StatusCode::UNPROCESSABLE_ENTITY, AppErrorCode::NetworkError | AppErrorCode::DatabaseError | AppErrorCode::IoError diff --git a/src-tauri/src/workspace_state/mod.rs b/src-tauri/src/workspace_state/mod.rs index cbb3bf7..38ffdf9 100644 --- a/src-tauri/src/workspace_state/mod.rs +++ b/src-tauri/src/workspace_state/mod.rs @@ -13,6 +13,7 @@ use tokio::sync::mpsc::error::TrySendError; use crate::app_error::AppCommandError; use crate::commands::folders::{self, FileTreeNode}; +use crate::git_repo::is_git_repo; use crate::web::event_bridge::{emit_event, EventEmitter}; pub const WORKSPACE_STATE_PROTOCOL_VERSION: u16 = 1; @@ -580,10 +581,6 @@ async fn git_numstat_map(path: &str) -> HashMap { .unwrap_or_default() } -fn is_git_repo(root: &Path) -> bool { - root.join(".git").exists() -} - async fn collect_git_snapshot(path: &str) -> Result, AppCommandError> { let status_entries = folders::git_status(path.to_string(), Some(true)).await?; diff --git a/src/components/layout/aux-panel-git-log-tab.tsx b/src/components/layout/aux-panel-git-log-tab.tsx index 7a4224b..6f2f442 100644 --- a/src/components/layout/aux-panel-git-log-tab.tsx +++ b/src/components/layout/aux-panel-git-log-tab.tsx @@ -79,6 +79,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { subscribe } from "@/lib/platform" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" +import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { getGitBranch, gitCommitBranches, @@ -692,11 +693,12 @@ export function GitLogTab() { const tCommon = useTranslations("Folder.common") const { folder } = useFolderContext() const { openCommitDiff, openFilePreview } = useWorkspaceContext() + const workspaceState = useWorkspaceStateStore(folder?.path ?? null) + const isGitRepo = workspaceState.isGitRepo const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) - const [notAGitRepo, setNotAGitRepo] = useState(false) const [scrolled, setScrolled] = useState(false) const [openByCommit, setOpenByCommit] = useState>({}) const [branchesByCommit, setBranchesByCommit] = useState< @@ -761,10 +763,13 @@ export function GitLogTab() { [folder?.path] ) - // Fetch branches on mount + // Fetch branches on mount and when git presence flips — the preflight + // check in `git_list_all_branches` would short-circuit on non-git folders + // anyway, but skipping the call saves an unnecessary round trip. useEffect(() => { + if (!isGitRepo) return void refreshBranches() - }, [refreshBranches]) + }, [isGitRepo, refreshBranches]) const fetchCommitBranches = useCallback( async (fullHash: string) => { @@ -809,7 +814,6 @@ export function GitLogTab() { setBranchesError({}) } setError(null) - setNotAGitRepo(false) try { const result = await gitLog(folder.path, 100, branch ?? undefined) setEntries(result.entries) @@ -832,7 +836,8 @@ export function GitLogTab() { } } catch (e) { if (isNotAGitRepoError(e)) { - setNotAGitRepo(true) + // Workspace state will flip isGitRepo within the next watch flush; + // clear entries so stale log data does not linger while we wait. setEntries([]) } else { setError(toErrorMessage(e)) @@ -958,8 +963,19 @@ export function GitLogTab() { ]) useEffect(() => { + if (!folder?.path) return + // Only fetch when workspaceState says we're in a git repo. When it flips + // (user runs `git init` / deletes `.git` externally), this effect re-runs + // and either re-fetches or clears the log to stay aligned with the other + // workspace panels. + if (!isGitRepo) { + setEntries([]) + setError(null) + setLoading(false) + return + } void fetchLog() - }, [fetchLog]) + }, [folder?.path, isGitRepo, fetchLog]) // Refresh branches & log on branch change, commit, or push useEffect(() => { @@ -1026,7 +1042,7 @@ export function GitLogTab() { ) } - if (notAGitRepo) { + if (!isGitRepo) { return (
diff --git a/src/i18n/git-error-patterns.ts b/src/i18n/git-error-patterns.ts new file mode 100644 index 0000000..118d712 --- /dev/null +++ b/src/i18n/git-error-patterns.ts @@ -0,0 +1,29 @@ +/** + * Locale-independent patterns that identify a "not a git repository" error + * from the raw stderr of a failed git invocation. + * + * Git translates its error messages via gettext based on the **system** + * LC_MESSAGES locale, which may differ from the user's browser locale. + * Detection therefore needs patterns for every language git might emit on + * the server, not just the one the UI is localized into. + * + * These patterns are a belt-and-suspenders fallback. The primary detection + * path is the typed `not_a_git_repository` error code returned by backend + * commands wrapped with a filesystem preflight check (see + * `src-tauri/src/commands/folders.rs::ensure_git_repo`). Patterns only apply + * when an un-preflighted command leaks raw stderr to the client. + * + * Locales covered match git's own gettext translations. Arabic falls back to + * English in upstream git (no ar translation), so no separate pattern. + */ +export const NOT_A_GIT_REPO_PATTERNS: readonly RegExp[] = [ + /not a git repository/i, // en + /不是\s*git\s*仓库/i, // zh-CN + /不是\s*git\s*儲存庫/i, // zh-TW + /git\s*リポジトリではありません/i, // ja + /git\s*저장소가\s*아닙니다/i, // ko + /kein\s*git[-\s]*repository/i, // de + /pas\s*(?:un|dans\s*un)\s*d[ée]p[oô]t\s*git/i, // fr + /no\s*es\s*un\s*repositorio\s*git/i, // es + /n[ãa]o\s*[ée]\s*um\s*reposit[oó]rio\s*git/i, // pt +] diff --git a/src/lib/app-error.ts b/src/lib/app-error.ts index f7a6e3e..d0dc815 100644 --- a/src/lib/app-error.ts +++ b/src/lib/app-error.ts @@ -1,3 +1,4 @@ +import { NOT_A_GIT_REPO_PATTERNS } from "@/i18n/git-error-patterns" import type { AppCommandError } from "@/lib/types" type ObjectLike = Record @@ -46,11 +47,19 @@ export function extractAppCommandError(error: unknown): AppCommandError | null { } } +// Must mirror `AppErrorCode::NotAGitRepository` in src-tauri/src/app_error.rs. +// If the backend enum ever renames, both sides must change together. +export const NOT_A_GIT_REPO_CODE = "not_a_git_repository" + export function isNotAGitRepoError(error: unknown): boolean { const appError = extractAppCommandError(error) + if (appError?.code === NOT_A_GIT_REPO_CODE) return true + const candidates = [appError?.detail, appError?.message] return candidates.some( - (text) => typeof text === "string" && /not a git repository/i.test(text) + (text) => + typeof text === "string" && + NOT_A_GIT_REPO_PATTERNS.some((pattern) => pattern.test(text)) ) }