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

@@ -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<GitLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notAGitRepo, setNotAGitRepo] = useState(false)
const [scrolled, setScrolled] = useState(false)
const [openByCommit, setOpenByCommit] = useState<Record<string, boolean>>({})
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 (
<ScrollArea className="h-full px-3 py-3">
<div className="flex flex-col items-center justify-center min-h-full gap-1 p-6 text-center">

View File

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

View File

@@ -1,3 +1,4 @@
import { NOT_A_GIT_REPO_PATTERNS } from "@/i18n/git-error-patterns"
import type { AppCommandError } from "@/lib/types"
type ObjectLike = Record<string, unknown>
@@ -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))
)
}