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.
This commit is contained in:
xintaofei
2026-04-18 23:07:13 +08:00
parent cf9573c0ce
commit cc79d62b27
9 changed files with 135 additions and 13 deletions

View File

@@ -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<String>) -> Self {
Self::new(AppErrorCode::NotAGitRepository, message)
}
pub fn already_exists(message: impl Into<String>) -> Self {
Self::new(AppErrorCode::AlreadyExists, message)
}

View File

@@ -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<Vec<String>, 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<GitPushInfo, AppCommandError> {
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<Vec<String>, 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<bool>,
) -> Result<Vec<GitStatusEntry>, 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<bool, AppComma
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn git_diff(path: String, file: Option<String>) -> Result<String, AppCommandError> {
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<String>,
) -> Result<String, AppCommandError> {
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<String>,
) -> Result<String, AppCommandError> {
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<String>,
) -> Result<String, AppCommandError> {
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<String>) -> Result<(), AppCo
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn git_list_all_branches(path: String) -> Result<GitBranchList, AppCommandError> {
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<GitBranchList, AppCom
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn git_list_remotes(path: String) -> Result<Vec<GitRemote>, 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<String>,
remote: Option<String>,
) -> Result<GitLogResult, AppCommandError> {
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<Vec<String>, AppCommandError> {
ensure_git_repo(&path)?;
let contains_arg = format!("--contains={commit}");
let output = crate::process::tokio_command("git")
.args([

40
src-tauri/src/git_repo.rs Normal file
View File

@@ -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}"
)))
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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<String, (i32, i32)> {
.unwrap_or_default()
}
fn is_git_repo(root: &Path) -> bool {
root.join(".git").exists()
}
async fn collect_git_snapshot(path: &str) -> Result<Vec<WorkspaceGitEntry>, AppCommandError> {
let status_entries = folders::git_status(path.to_string(), Some(true)).await?;