From 4483c8f0e6d707c0b62bc6c5da202278b22f041c Mon Sep 17 00:00:00 2001 From: guantw Date: Tue, 11 Aug 2026 18:20:59 +0800 Subject: [PATCH] fix(git): recover untrusted repository operations with a confirmation flow - detect dubious ownership at the service boundary and raise typed trust errors instead of raw failures - confirm the repository root through a unified interactive flow that writes safe.directory and replays the blocked operation once - keep trust probes read-only and non-interactive paths prompt-free; never replay side-effecting commands - provide an authorization entry on the Git scene trust-required state and localized manual-resolution copy for uncertain or unsupported outcomes - serialize Deep Review target Git reads so an interactive trust decision settles before sibling bounded-evidence requests - normalize trust prompt dedupe keys per platform (Windows/UNC case-fold, POSIX case-safe) and scope read-only graph queries to non-interactive mode - tighten path identity and scope checks on Windows and harden refresh lifecycle after disposal --- .gitattributes | 2 + src/apps/desktop/src/api/git_api.rs | 385 +++++++- .../src/api/remote_workspace_policy.rs | 8 + src/apps/desktop/src/lib.rs | 2 + .../services-integrations/src/git/error.rs | 50 + .../src/git/managed_worktree.rs | 64 +- .../src/git/runtime_port.rs | 5 +- .../services-integrations/src/git/service.rs | 237 ++++- .../services-integrations/src/git/types.rs | 27 + .../services-integrations/src/git/utils.rs | 914 +++++++++++++++++- src/web-ui/src/app/App.tsx | 6 +- .../NavPanel/components/BranchQuickSwitch.tsx | 4 +- .../components/panels/base/FlexiblePanel.tsx | 2 + src/web-ui/src/app/scenes/git/GitNav.tsx | 10 +- src/web-ui/src/app/scenes/git/GitScene.scss | 12 +- src/web-ui/src/app/scenes/git/GitScene.tsx | 119 ++- src/web-ui/src/app/scenes/git/appearance.ts | 2 +- .../src/app/scenes/git/views/BranchesView.tsx | 20 +- .../src/app/scenes/git/views/GraphView.tsx | 2 +- .../app/scenes/git/views/WorkingCopyView.tsx | 37 +- .../ConfirmDialog/ConfirmDialog.tsx | 19 + .../flow_chat/components/ChatEmptyState.tsx | 2 +- .../components/WelcomePanel.test.tsx | 4 +- .../src/flow_chat/components/WelcomePanel.tsx | 4 +- .../deep-review/launch/targetResolver.test.ts | 64 +- .../deep-review/launch/targetResolver.ts | 127 ++- .../services/DeepReviewService.test.ts | 10 +- .../api/errors/TauriCommandError.test.ts | 43 + .../api/errors/TauriCommandError.ts | 181 +++- .../api/service-api/ApiClient.test.ts | 44 +- .../api/service-api/GitAPI.test.ts | 256 ++++- .../infrastructure/api/service-api/GitAPI.ts | 817 ++++++++++------ .../api/service-api/GitTrustErrors.ts | 26 + .../GitTrustPromptRenderer.test.tsx | 91 ++ .../service-api/GitTrustPromptRenderer.tsx | 102 ++ .../service-api/GitTrustPromptService.test.ts | 140 +++ .../api/service-api/GitTrustPromptService.ts | 235 +++++ src/web-ui/src/locales/en-US/panels/git.json | 33 + src/web-ui/src/locales/zh-CN/panels/git.json | 33 + src/web-ui/src/locales/zh-TW/panels/git.json | 33 + .../git/GitTrustOutcomePresenter.test.ts | 89 ++ .../src/tools/git/GitTrustOutcomePresenter.ts | 75 ++ .../GitBranchHistoryView.tsx | 25 +- .../components/GitGraphView/GitGraphView.tsx | 23 +- .../src/tools/git/hooks/useGitOperations.ts | 54 +- .../src/tools/git/hooks/useGitState.test.tsx | 32 +- src/web-ui/src/tools/git/hooks/useGitState.ts | 11 +- src/web-ui/src/tools/git/index.ts | 6 + .../src/tools/git/services/GitService.ts | 215 +++- .../tools/git/state/GitStateManager.test.ts | 174 +++- .../src/tools/git/state/GitStateManager.ts | 117 ++- src/web-ui/src/tools/git/state/types.ts | 17 +- src/web-ui/src/tools/git/types/graph.ts | 10 +- src/web-ui/src/tools/git/types/operations.ts | 5 +- 54 files changed, 4359 insertions(+), 666 deletions(-) create mode 100644 src/web-ui/src/infrastructure/api/service-api/GitTrustErrors.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/GitTrustPromptRenderer.test.tsx create mode 100644 src/web-ui/src/infrastructure/api/service-api/GitTrustPromptRenderer.tsx create mode 100644 src/web-ui/src/infrastructure/api/service-api/GitTrustPromptService.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/GitTrustPromptService.ts create mode 100644 src/web-ui/src/tools/git/GitTrustOutcomePresenter.test.ts create mode 100644 src/web-ui/src/tools/git/GitTrustOutcomePresenter.ts diff --git a/.gitattributes b/.gitattributes index 0e96cc7c3..d7fa9df24 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,6 +22,8 @@ *.py text eol=lf *.mjs text eol=lf *.cjs text eol=lf +*.ts text eol=lf +*.tsx text eol=lf # models.dev provenance hashes exact redistributed bytes. Keep these assets # stable across checkout platforms so the offline release check is reproducible. diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index d98813fda..c0c8e4997 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -5,8 +5,8 @@ use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::storage::StorageOptions; use bitfun_core::service::git::{ build_git_changed_files_args, build_git_diff_args, parse_name_status_output, GitAddParams, - GitChangedFile, GitChangedFilesParams, GitCommitParams, GitDiffParams, GitFileStatus, - GitLogParams, GitPullParams, GitPushParams, GitService, + GitChangedFile, GitChangedFilesParams, GitCommitParams, GitDiffParams, GitError, GitFileStatus, + GitLogParams, GitPullParams, GitPushParams, GitService, GitTrustResult, }; use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, @@ -18,6 +18,7 @@ use bitfun_core::service::remote_ssh::{ use bitfun_core::service::workspace::WorktreeTopologyFreshness; use log::{error, info}; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; use std::time::Instant; use tauri::State; @@ -46,6 +47,206 @@ fn build_remote_git_command(repository_path: &str, args: &[String]) -> String { build_remote_git_command_shared(repository_path, args) } +fn format_git_service_error(context: &str, error: &GitError) -> String { + match error { + GitError::RepositoryTrustRequired { + requested_path, + repository_path, + operation, + detected_by, + } => serde_json::json!({ + "code": "git_repository_trust_required", + "message": "Git repository trust is required before this operation can continue.", + "data": { + "requestedPath": requested_path, + "repositoryPath": repository_path, + "operation": operation.as_deref().unwrap_or(context), + "detectedBy": detected_by, + "retryable": true, + } + }) + .to_string(), + GitError::RepositoryChanged { + expected_path, + actual_path, + } => serde_json::json!({ + "code": "git_repository_changed", + "message": "The Git repository changed while trust authorization was pending.", + "data": { + "expectedPath": expected_path, + "actualPath": actual_path, + "retryable": false, + } + }) + .to_string(), + GitError::TrustAddFailed { + repository_path, + reason, + } => serde_json::json!({ + "code": "git_trust_add_failed", + "message": "The Git trust directory could not be added or verified.", + "data": { + "repositoryPath": repository_path, + "reason": reason, + "retryable": false, + } + }) + .to_string(), + GitError::TrustUnsupported { + repository_path, + reason, + } => serde_json::json!({ + "code": "git_trust_unsupported", + "message": "Git repository trust cannot be changed in this execution context.", + "data": { + "repositoryPath": repository_path, + "reason": reason, + "retryable": false, + } + }) + .to_string(), + GitError::TrustDetectionUncertain { + repository_path, + reason, + } => serde_json::json!({ + "code": "git_trust_detection_uncertain", + "message": "Git reported an inconclusive repository trust signal; no trust configuration was changed.", + "data": { + "repositoryPath": repository_path, + "reason": reason, + "retryable": false, + } + }) + .to_string(), + GitError::RepositoryNotFound(repository_path) => serde_json::json!({ + "code": "git_repository_not_found", + "message": format!("{context}: repository was not found"), + "data": { + "repositoryPath": repository_path, + "retryable": false, + } + }) + .to_string(), + error => { + let reason = error.to_string(); + let lower_reason = reason.to_ascii_lowercase(); + let permission_denied = lower_reason.contains("permission denied") + || lower_reason.contains("access denied") + || lower_reason.contains("operation not permitted"); + let code = permission_denied + .then_some("git_permission_denied") + .unwrap_or("git_command_failed"); + serde_json::json!({ + "code": code, + "message": format!("{context}: {reason}"), + "data": { + "reason": reason, + "retryable": false, + } + }) + .to_string() + } + } +} + +fn normalize_canonical_git_path(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + let value = path.to_string_lossy(); + if let Some(unc_path) = value.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{unc_path}")); + } + if let Some(drive_path) = value.strip_prefix(r"\\?\") { + return PathBuf::from(drive_path); + } + } + + path +} + +fn canonicalize_git_trust_context_path(path: &Path) -> Result { + if !path.is_dir() { + return Err(GitError::TrustUnsupported { + repository_path: path.to_string_lossy().into_owned(), + reason: "Git trust can only be granted to an existing directory".to_string(), + }); + } + + std::fs::canonicalize(path) + .map(normalize_canonical_git_path) + .map_err(|error| GitError::TrustUnsupported { + repository_path: path.to_string_lossy().into_owned(), + reason: format!("Failed to canonicalize Git trust path: {error}"), + }) +} + +fn ensure_canonical_git_trust_identity( + expected_repository: &Path, + actual_repository: &Path, +) -> Result<(), GitError> { + if actual_repository == expected_repository { + return Ok(()); + } + + Err(GitError::RepositoryChanged { + expected_path: expected_repository.to_string_lossy().into_owned(), + actual_path: actual_repository.to_string_lossy().into_owned(), + }) +} + +async fn validate_git_trust_workspace( + state: &State<'_, AppState>, + repository_path: &str, + expected_repository_path: &str, +) -> Result { + let expected = normalize_canonical_git_path(PathBuf::from(expected_repository_path)); + let requested = canonicalize_git_trust_context_path(Path::new(repository_path))?; + ensure_canonical_git_trust_identity(&expected, &requested)?; + validate_git_trust_workspace_scope(state, requested).await +} + +async fn validate_git_trust_workspace_scope( + state: &State<'_, AppState>, + requested: PathBuf, +) -> Result { + let requested_path = requested.to_string_lossy().into_owned(); + let current_workspace = + state + .workspace_path + .read() + .await + .clone() + .ok_or_else(|| GitError::TrustUnsupported { + repository_path: requested_path.clone(), + reason: "There is no active local Desktop workspace".to_string(), + })?; + let current = canonicalize_git_trust_context_path(¤t_workspace)?; + + if requested == current || requested.starts_with(¤t) { + return Ok(requested); + } + + // A linked worktree may live outside the active workspace. Let the + // workspace-owned topology registry prove that it is a live sibling of + // the registered worktree before allowing the trust write. + match state + .workspace_service + .is_live_worktree_root_in_same_repository(¤t, &requested) + .await + { + Ok(true) => Ok(requested), + Ok(false) => Err(GitError::TrustUnsupported { + repository_path: requested_path, + reason: "The repository is not a registered live worktree of the active workspace" + .to_string(), + }), + Err(error) => Err(GitError::TrustUnsupported { + repository_path: requested_path, + reason: format!("The worktree relationship could not be verified: {error}"), + }), + } +} + async fn execute_remote_git_command( state: &AppState, target: &RemoteGitTarget, @@ -350,6 +551,24 @@ pub struct GitRepositoryRequest { pub repository_path: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitTrustRepositoryRequest { + pub repository_path: String, + /// Canonical repository identity captured when the trust prompt was shown. + /// This is intentionally separate from the path that is resolved again at + /// approval time so a replacement symlink cannot silently change the + /// user's confirmation target. + pub expected_repository_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitTrustProbeRequest { + pub repository_path: String, + pub operation: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitResolveRevisionRequest { @@ -507,7 +726,7 @@ pub async fn git_is_repository( "Failed to check Git repository: path={}, error={}", request.repository_path, e ); - format!("Failed to check Git repository: {}", e) + format_git_service_error("Failed to check Git repository", &e) }) }; @@ -515,6 +734,72 @@ pub async fn git_is_repository( result } +#[tauri::command] +pub async fn git_probe_repository_trust( + state: State<'_, AppState>, + request: GitTrustProbeRequest, +) -> Result<(), String> { + if resolve_remote_git_target(&request.repository_path) + .await + .is_some() + { + return Err(format_git_service_error( + "Git trust confirmation probe", + &GitError::TrustUnsupported { + repository_path: request.repository_path, + reason: "Remote Git trust must be confirmed in the remote execution domain" + .to_string(), + }, + )); + } + + let canonical_repository = GitService::prepare_repository_trust_probe(&request.repository_path) + .await + .map_err(|error| format_git_service_error("Git trust confirmation probe", &error))?; + let canonical_repository = validate_git_trust_workspace_scope(&state, canonical_repository) + .await + .map_err(|error| format_git_service_error("Git trust confirmation probe", &error))?; + let context = request + .operation + .as_deref() + .unwrap_or("Git trust confirmation probe"); + + GitService::probe_repository_trust(&canonical_repository) + .await + .map_err(|error| format_git_service_error(context, &error)) +} + +#[tauri::command] +pub async fn git_trust_repository( + state: State<'_, AppState>, + request: GitTrustRepositoryRequest, +) -> Result { + if resolve_remote_git_target(&request.repository_path) + .await + .is_some() + { + return Err(format_git_service_error( + "Git trust is unsupported for remote workspaces", + &GitError::TrustUnsupported { + repository_path: request.repository_path, + reason: "Remote Git trust must be managed in the remote execution domain" + .to_string(), + }, + )); + } + + let canonical_repository = validate_git_trust_workspace( + &state, + &request.repository_path, + &request.expected_repository_path, + ) + .await + .map_err(|error| format_git_service_error("Failed to validate Git trust path", &error))?; + GitService::trust_repository_at_canonical_root(&canonical_repository) + .await + .map_err(|error| format_git_service_error("Failed to trust Git repository", &error)) +} + #[tauri::command] pub async fn git_get_repository( state: State<'_, AppState>, @@ -573,7 +858,7 @@ pub async fn git_get_repository( "Failed to get Git repository info: path={}, error={}", request.repository_path, e ); - format!("Failed to get Git repository info: {}", e) + format_git_service_error("Failed to get Git repository info", &e) }) } @@ -624,7 +909,7 @@ pub async fn git_get_repository_basic( "Failed to get basic Git repository info: path={}, error={}", request.repository_path, e ); - format!("Failed to get basic Git repository info: {}", e) + format_git_service_error("Failed to get basic Git repository info", &e) }) } } @@ -660,7 +945,7 @@ pub async fn git_resolve_revision( GitService::resolve_revision(&request.repository_path, revision) .await - .map_err(|e| format!("Failed to resolve Git revision: {}", e)) + .map_err(|e| format_git_service_error("Failed to resolve Git revision", &e)) } #[tauri::command] @@ -690,7 +975,7 @@ pub async fn git_get_status( "Failed to get Git status: path={}, error={}", request.repository_path, e ); - format!("Failed to get Git status: {}", e) + format_git_service_error("Failed to get Git status", &e) }) } @@ -720,7 +1005,7 @@ pub async fn git_get_branches( "Failed to get Git branches: path={}, include_remote={}, error={}", request.repository_path, include_remote, e ); - format!("Failed to get Git branches: {}", e) + format_git_service_error("Failed to get Git branches", &e) }) } @@ -750,7 +1035,7 @@ pub async fn git_get_enhanced_branches( "Failed to get enhanced Git branches: path={}, include_remote={}, error={}", request.repository_path, include_remote, e ); - format!("Failed to get enhanced Git branches: {}", e) + format_git_service_error("Failed to get enhanced Git branches", &e) }) } @@ -772,7 +1057,7 @@ pub async fn git_get_commits( "Failed to get Git commits: path={}, error={}", request.repository_path, e ); - format!("Failed to get Git commits: {}", e) + format_git_service_error("Failed to get Git commits", &e) }) } @@ -800,7 +1085,7 @@ pub async fn git_add_files( "Failed to add files: path={}, error={}", request.repository_path, e ); - format!("Failed to add files: {}", e) + format_git_service_error("Failed to add files", &e) }) } @@ -838,7 +1123,7 @@ pub async fn git_commit( "Failed to commit: path={}, error={}", request.repository_path, e ); - format!("Failed to commit: {}", e) + format_git_service_error("Failed to commit", &e) }) } @@ -871,7 +1156,7 @@ pub async fn git_push( "Failed to push: path={}, error={}", request.repository_path, e ); - format!("Failed to push: {}", e) + format_git_service_error("Failed to push", &e) }) } @@ -901,7 +1186,7 @@ pub async fn git_pull( "Failed to pull: path={}, error={}", request.repository_path, e ); - format!("Failed to pull: {}", e) + format_git_service_error("Failed to pull", &e) }) } @@ -926,7 +1211,7 @@ pub async fn git_checkout_branch( "Failed to checkout branch: path={}, branch={}, error={}", request.repository_path, request.branch_name, e ); - format!("Failed to checkout branch: {}", e) + format_git_service_error("Failed to checkout branch", &e) }) } @@ -958,7 +1243,7 @@ pub async fn git_create_branch( "Failed to create branch: path={}, branch={}, error={}", request.repository_path, request.branch_name, e ); - format!("Failed to create branch: {}", e) + format_git_service_error("Failed to create branch", &e) }) } @@ -988,7 +1273,7 @@ pub async fn git_delete_branch( "Failed to delete branch: path={}, branch={}, force={}, error={}", request.repository_path, request.branch_name, force, e ); - format!("Failed to delete branch: {}", e) + format_git_service_error("Failed to delete branch", &e) }) } @@ -1009,7 +1294,7 @@ pub async fn git_get_diff( "Failed to get Git diff: path={}, error={}", request.repository_path, e ); - format!("Failed to get Git diff: {}", e) + format_git_service_error("Failed to get Git diff", &e) }) } @@ -1037,7 +1322,7 @@ pub async fn git_get_changed_files( .await .map_err(|e| { error!("Failed to get changed Git files: {}", e); - e.to_string() + format_git_service_error("Failed to get changed Git files", &e) }) } @@ -1071,7 +1356,7 @@ pub async fn git_reset_files( output: Some(output), duration: None, }) - .map_err(|e| e.to_string()) + .map_err(|e| format_git_service_error("Failed to reset files", &e)) } #[tauri::command] @@ -1100,7 +1385,7 @@ pub async fn git_get_file_content( request.commit.as_deref(), ) .await - .map_err(|e| e.to_string())?; + .map_err(|e| format_git_service_error("Failed to get Git file content", &e))?; Ok(content) } @@ -1145,7 +1430,7 @@ pub async fn git_reset_to_commit( "Failed to reset to commit: path={}, commit={}, mode={}, error={}", request.repository_path, request.commit_hash, request.mode, e ); - format!("Failed to reset: {}", e) + format_git_service_error("Failed to reset", &e) }) } @@ -1167,7 +1452,7 @@ pub async fn git_get_graph( GitService::get_git_graph_for_branch(&repository_path, max_count, branch_name) .await - .map_err(|e| e.to_string()) + .map_err(|e| format_git_service_error("Failed to get Git graph", &e)) } #[tauri::command] @@ -1198,7 +1483,7 @@ pub async fn git_cherry_pick( "Failed to cherry-pick: path={}, commit={}, no_commit={}, error={}", request.repository_path, request.commit_hash, no_commit, e ); - format!("Failed to cherry-pick: {}", e) + format_git_service_error("Failed to cherry-pick", &e) }) } @@ -1225,7 +1510,7 @@ pub async fn git_cherry_pick_abort( "Failed to abort cherry-pick: path={}, error={}", request.repository_path, e ); - format!("Failed to abort cherry-pick: {}", e) + format_git_service_error("Failed to abort cherry-pick", &e) }) } @@ -1255,7 +1540,7 @@ pub async fn git_cherry_pick_continue( "Failed to continue cherry-pick: path={}, error={}", request.repository_path, e ); - format!("Failed to continue cherry-pick: {}", e) + format_git_service_error("Failed to continue cherry-pick", &e) }) } @@ -1285,7 +1570,7 @@ pub async fn git_list_worktrees( "Failed to list worktrees: path={}, error={}", request.repository_path, e ); - format!("Failed to list worktrees: {}", e) + format_git_service_error("Failed to list worktrees", &e) }) } @@ -1315,7 +1600,7 @@ pub async fn git_add_worktree( "Failed to add worktree: path={}, branch={}, create_branch={}, error={}", request.repository_path, request.branch, create_branch, e ); - format!("Failed to add worktree: {}", e) + format_git_service_error("Failed to add worktree", &e) })?; state .workspace_service @@ -1350,7 +1635,7 @@ pub async fn git_remove_worktree( "Failed to remove worktree: path={}, worktree_path={}, force={}, error={}", request.repository_path, request.worktree_path, force, e ); - format!("Failed to remove worktree: {}", e) + format_git_service_error("Failed to remove worktree", &e) })?; state .workspace_service @@ -1423,3 +1708,45 @@ pub async fn load_git_repo_history( None => Ok(Vec::new()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trust_identity_rejects_a_different_canonical_root() { + let error = ensure_canonical_git_trust_identity( + Path::new("expected-repository"), + Path::new("replacement-repository"), + ) + .expect_err("a changed repository must fail closed"); + + assert!(matches!( + error, + GitError::RepositoryChanged { + expected_path, + actual_path, + } if expected_path == "expected-repository" && actual_path == "replacement-repository" + )); + } + + #[test] + fn probe_unsupported_envelope_preserves_service_diagnostics() { + let encoded = format_git_service_error( + "Git trust confirmation probe", + &GitError::TrustUnsupported { + repository_path: "repository".to_string(), + reason: "The read-only probe did not confirm owner rejection".to_string(), + }, + ); + let envelope: serde_json::Value = + serde_json::from_str(&encoded).expect("service errors must be JSON envelopes"); + + assert_eq!(envelope["code"], "git_trust_unsupported"); + assert_eq!(envelope["data"]["repositoryPath"], "repository"); + assert_eq!( + envelope["data"]["reason"], + "The read-only probe did not confirm owner rejection" + ); + } +} diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 2bed24916..545c06e9a 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -812,6 +812,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("git_get_status", RemoteWorkspacePolicy::RemoteRouted), ("git_is_repository", RemoteWorkspacePolicy::RemoteRouted), + ( + "git_probe_repository_trust", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "git_trust_repository", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "git_list_worktrees", RemoteWorkspacePolicy::RemoteUnsupported, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 0eefe5674..e24d0a886 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1414,6 +1414,8 @@ pub async fn run() { add_skill, delete_skill, git_is_repository, + git_probe_repository_trust, + git_trust_repository, git_get_repository_basic, git_resolve_revision, git_get_repository, diff --git a/src/crates/services/services-integrations/src/git/error.rs b/src/crates/services/services-integrations/src/git/error.rs index c00cd2659..be7c9a8ff 100644 --- a/src/crates/services/services-integrations/src/git/error.rs +++ b/src/crates/services/services-integrations/src/git/error.rs @@ -1,3 +1,5 @@ +use super::types::GitTrustDetectionSource; + #[derive(Debug, thiserror::Error)] pub enum GitError { #[error("Repository not found: {0}")] @@ -29,4 +31,52 @@ pub enum GitError { #[error("Git2 error: {0}")] Git2Error(#[from] git2::Error), + + #[error("Git repository trust is required for {repository_path}")] + RepositoryTrustRequired { + requested_path: String, + repository_path: String, + operation: Option, + detected_by: GitTrustDetectionSource, + }, + + #[error("Git repository changed during trust authorization: expected {expected_path}, found {actual_path}")] + RepositoryChanged { + expected_path: String, + actual_path: String, + }, + + #[error("Failed to add Git repository to safe.directory: {reason}")] + TrustAddFailed { + repository_path: String, + reason: String, + }, + + #[error("Git trust operation is unsupported: {reason}")] + TrustUnsupported { + repository_path: String, + reason: String, + }, + + #[error("Git trust detection was inconclusive for {repository_path}: {reason}")] + TrustDetectionUncertain { + repository_path: String, + reason: String, + }, +} + +impl GitError { + /// Add context without hiding errors that have a stable machine-readable + /// meaning for the Desktop integration. + pub fn with_context(self, context: impl Into) -> Self { + let context = context.into(); + match self { + Self::RepositoryTrustRequired { .. } + | Self::RepositoryChanged { .. } + | Self::TrustAddFailed { .. } + | Self::TrustUnsupported { .. } + | Self::TrustDetectionUncertain { .. } => self, + error => Self::CommandFailed(format!("{context}: {error}")), + } + } } diff --git a/src/crates/services/services-integrations/src/git/managed_worktree.rs b/src/crates/services/services-integrations/src/git/managed_worktree.rs index fbe33b1df..71a90713a 100644 --- a/src/crates/services/services-integrations/src/git/managed_worktree.rs +++ b/src/crates/services/services-integrations/src/git/managed_worktree.rs @@ -1,9 +1,11 @@ use super::service::GitService; use super::types::{GitLocalChangeSummary, GitWorktreeInfo}; -use super::utils::execute_git_command; -use super::GitError; +use super::utils::{ + apply_git_cli_env_tokio, classify_or_wrap_git_command_failure, execute_git_command, + open_repository, +}; +use super::{GitCommandOutput, GitError}; use bitfun_services_core::process_manager; -use git2::Repository; use std::path::{Component, Path, PathBuf}; use std::process::Stdio; use tokio::io::AsyncWriteExt; @@ -56,7 +58,9 @@ fn validate_relative_file_path(path: &str) -> Result { } async fn git_output_bytes(repo_path: &Path, args: &[&str]) -> Result, GitError> { - let output = process_manager::create_tokio_command("git") + let mut command = process_manager::create_tokio_command("git"); + apply_git_cli_env_tokio(&mut command); + let output = command .current_dir(repo_path) .env("GIT_TERMINAL_PROMPT", "0") .args(args) @@ -65,16 +69,18 @@ async fn git_output_bytes(repo_path: &Path, args: &[&str]) -> Result, Gi .map_err(|error| { GitError::CommandFailed(format!("Failed to execute git command: {error}")) })?; - if output.status.success() { + let command_output = GitCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + }; + if command_output.exit_code == 0 { Ok(output.stdout) } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Err(GitError::CommandFailed(if stderr.is_empty() { - stdout - } else { - stderr - })) + Err(classify_or_wrap_git_command_failure( + &repo_path.to_string_lossy(), + &command_output, + )) } } @@ -83,8 +89,9 @@ async fn git_with_stdin(repo_path: &Path, args: &[&str], input: &[u8]) -> Result return Ok(()); } - let mut child = process_manager::create_tokio_command("git"); - child + let mut command = process_manager::create_tokio_command("git"); + apply_git_cli_env_tokio(&mut command); + command .current_dir(repo_path) .env("GIT_TERMINAL_PROMPT", "0") .args(args) @@ -92,7 +99,7 @@ async fn git_with_stdin(repo_path: &Path, args: &[&str], input: &[u8]) -> Result .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - let mut child = child.spawn().map_err(|error| { + let mut child = command.spawn().map_err(|error| { GitError::CommandFailed(format!("Failed to execute git command: {error}")) })?; let mut stdin = child @@ -108,16 +115,18 @@ async fn git_with_stdin(repo_path: &Path, args: &[&str], input: &[u8]) -> Result let output = child.wait_with_output().await.map_err(|error| { GitError::CommandFailed(format!("Failed to wait for git command: {error}")) })?; - if output.status.success() { + let command_output = GitCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + }; + if command_output.exit_code == 0 { Ok(()) } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Err(GitError::CommandFailed(if stderr.is_empty() { - stdout - } else { - stderr - })) + Err(classify_or_wrap_git_command_failure( + &repo_path.to_string_lossy(), + &command_output, + )) } } @@ -285,10 +294,8 @@ impl GitService { let inspect_path = target_path.clone(); task::spawn_blocking(move || { - let repository = Repository::open(&inspect_path).map_err(|error| { - GitError::CommandFailed(format!( - "Failed to inspect newly created detached worktree: {error}" - )) + let repository = open_repository(&inspect_path).map_err(|error| { + error.with_context("Failed to inspect newly created detached worktree") })?; let head = repository .head() @@ -384,8 +391,7 @@ impl GitService { worktree_path: P, ) -> Result { let worktree_path = worktree_path.as_ref(); - let repository = Repository::open(worktree_path) - .map_err(|error| GitError::RepositoryNotFound(error.to_string()))?; + let repository = open_repository(worktree_path)?; if repository.head().ok().is_some_and(|head| head.is_branch()) { return Ok(false); } diff --git a/src/crates/services/services-integrations/src/git/runtime_port.rs b/src/crates/services/services-integrations/src/git/runtime_port.rs index 253b44f56..994f5ac3b 100644 --- a/src/crates/services/services-integrations/src/git/runtime_port.rs +++ b/src/crates/services/services-integrations/src/git/runtime_port.rs @@ -11,7 +11,7 @@ use git2::{ Delta, DiffFindOptions, DiffFlags, DiffOptions, Patch, Repository, Status, StatusOptions, }; -use super::GitError; +use super::{utils::discover_repository, GitError}; const MAX_WORKSPACE_DIFF_FILES: usize = 256; const MAX_WORKSPACE_DIFF_FILE_BYTES: usize = 1024 * 1024; @@ -48,8 +48,7 @@ impl GitPort for GitWorkspaceDiffPort { } fn collect_workspace_diff(workspace_root: &Path) -> Result { - let repository = Repository::discover(workspace_root) - .map_err(|error| GitError::RepositoryNotFound(error.to_string()))?; + let repository = discover_repository(workspace_root)?; let repository_root = repository .workdir() .ok_or_else(|| GitError::InvalidPath("Repository has no working directory".to_string()))? diff --git a/src/crates/services/services-integrations/src/git/service.rs b/src/crates/services/services-integrations/src/git/service.rs index 1db95ffcc..7fde3e98c 100644 --- a/src/crates/services/services-integrations/src/git/service.rs +++ b/src/crates/services/services-integrations/src/git/service.rs @@ -10,14 +10,47 @@ use std::time::Instant; use tokio::task; use tokio::time::timeout; +use super::utils::{ + discover_repository, is_git_repository, open_repository, + probe_repository_trust_at_canonical_root as probe_repository_trust_impl, + resolve_repository_trust_root as resolve_repository_trust_root_impl, + trust_repository as trust_repository_impl, + trust_repository_at_canonical_root as trust_repository_at_canonical_root_impl, +}; + pub struct GitService; +const TRUST_PROBE_MANUAL_GUIDANCE: &str = "The read-only confirmation probe did not confirm a Git owner rejection. BitFun did not change safe.directory; review the repository and configure Git safe.directory manually if appropriate."; + type CommitStats = (Option, Option, Option); fn elapsed_ms_u64(started_at: Instant) -> u64 { started_at.elapsed().as_millis() as u64 } +fn map_trust_probe_error(repository_path: &Path, error: GitError) -> GitError { + match error { + error @ (GitError::RepositoryTrustRequired { .. } | GitError::TrustUnsupported { .. }) => { + error + } + GitError::TrustDetectionUncertain { reason, .. } => GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason, + }, + error => GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason: error.to_string(), + }, + } +} + +fn trust_probe_success_error(repository_path: &Path) -> GitError { + GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason: TRUST_PROBE_MANUAL_GUIDANCE.to_string(), + } +} + fn review_path_has_parent_traversal(path: &str, windows: bool) -> bool { if windows { path.replace('\\', "/") @@ -92,11 +125,83 @@ impl GitService { /// Checks whether the path is a Git repository. pub async fn is_repository>(path: P) -> Result { let path_buf = path.as_ref().to_path_buf(); - task::spawn_blocking(move || Ok(is_git_repository(path_buf))) + task::spawn_blocking(move || is_git_repository(path_buf)) .await .map_err(|e| GitError::CommandFailed(format!("spawn_blocking join: {e}")))? } + /// Resolves the canonical worktree root used by a read-only trust probe. + /// Probe preparation has its own error contract: path resolution and + /// repository-open failures are unsupported probe outcomes, never write + /// failures, because this path cannot modify Git configuration. + pub async fn prepare_repository_trust_probe>( + path: P, + ) -> Result { + let path = path.as_ref().to_path_buf(); + let display_path = path.to_string_lossy().into_owned(); + task::spawn_blocking(move || { + let requested_path = path.to_string_lossy().into_owned(); + resolve_repository_trust_root_impl(&requested_path) + .map_err(|error| map_trust_probe_error(Path::new(&requested_path), error)) + }) + .await + .map_err(|error| GitError::TrustUnsupported { + repository_path: display_path, + reason: format!("spawn_blocking join: {error}"), + })? + } + + /// Resolves the canonical worktree root used by the write flow without + /// changing its existing write-error semantics. + pub async fn resolve_repository_trust_root>( + path: P, + ) -> Result { + let path = path.as_ref().to_path_buf(); + task::spawn_blocking(move || resolve_repository_trust_root_impl(&path.to_string_lossy())) + .await + .map_err(|error| GitError::CommandFailed(format!("spawn_blocking join: {error}")))? + } + + /// Performs a fixed, read-only confirmation of an inconclusive Git trust + /// signal. The probe only upgrades to `RepositoryTrustRequired` when the + /// backend's typed owner check confirms the rejection. A successful probe + /// is deliberately a terminal `TrustUnsupported` outcome: the probe does + /// not authorize a configuration write and never replays the original + /// command. + pub async fn probe_repository_trust>(path: P) -> Result<(), GitError> { + let path = path.as_ref().to_path_buf(); + let display_path = path.to_string_lossy().into_owned(); + task::spawn_blocking(move || match probe_repository_trust_impl(&path) { + Ok(()) => Err(trust_probe_success_error(&path)), + Err(error) => Err(map_trust_probe_error(&path, error)), + }) + .await + .map_err(|error| GitError::TrustUnsupported { + repository_path: display_path, + reason: format!("spawn_blocking join: {error}"), + })? + } + + /// Adds the canonical worktree root to the user's global Git trust list + /// after the caller has obtained explicit consent. + pub async fn trust_repository>(path: P) -> Result { + let path = path.as_ref().to_path_buf(); + task::spawn_blocking(move || trust_repository_impl(&path.to_string_lossy())) + .await + .map_err(|error| GitError::CommandFailed(format!("spawn_blocking join: {error}")))? + } + + /// Adds trust only when the previously validated canonical worktree root + /// still resolves to itself at the write boundary. + pub async fn trust_repository_at_canonical_root>( + path: P, + ) -> Result { + let path = path.as_ref().to_path_buf(); + task::spawn_blocking(move || trust_repository_at_canonical_root_impl(&path)) + .await + .map_err(|error| GitError::CommandFailed(format!("spawn_blocking join: {error}")))? + } + /// Resolves the stable repository identity shared by all worktrees without /// spawning the Git CLI. pub async fn resolve_worktree_repository>( @@ -104,8 +209,7 @@ impl GitService { ) -> Result { let requested_path = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repository = Repository::discover(&requested_path) - .map_err(|error| GitError::RepositoryNotFound(error.to_string()))?; + let repository = discover_repository(&requested_path)?; let query_path = repository .workdir() .map(Path::to_path_buf) @@ -148,8 +252,7 @@ impl GitService { } task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; let object = repo .revparse_single(&revision) .map_err(|e| GitError::CommandFailed(format!("Failed to resolve revision: {e}")))?; @@ -216,8 +319,7 @@ impl GitService { pub async fn get_repository>(path: P) -> Result { let path_buf = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; let current_branch = get_current_branch(&repo)?; let is_bare = repo.is_bare(); @@ -254,8 +356,7 @@ impl GitService { pub async fn get_repository_basic>(path: P) -> Result { let path_buf = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; let current_branch = get_current_branch(&repo)?; let is_bare = repo.is_bare(); @@ -286,8 +387,7 @@ impl GitService { timeout( Duration::from_secs(10), task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; let current_branch = get_current_branch(&repo)?; let file_statuses = get_file_statuses(&repo)?; @@ -340,8 +440,7 @@ impl GitService { ) -> Result, GitError> { let path_buf = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; let mut branches = Vec::new(); let current_branch = get_current_branch(&repo)?; @@ -476,8 +575,7 @@ impl GitService { let path_buf = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; let current_branch = get_current_branch(&repo)?; for branch in &mut branches { @@ -667,8 +765,7 @@ impl GitService { ) -> Result, GitError> { let path_buf = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; let since_timestamp = params .since .as_deref() @@ -1228,8 +1325,7 @@ impl GitService { ) -> Result { let path_buf = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; build_git_graph(&repo, max_count).map_err(|e| GitError::CommandFailed(e.to_string())) }) .await @@ -1244,8 +1340,7 @@ impl GitService { ) -> Result { let path_buf = path.as_ref().to_path_buf(); task::spawn_blocking(move || { - let repo = Repository::open(&path_buf) - .map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = open_repository(&path_buf)?; build_git_graph_for_branch(&repo, max_count, branch_name.as_deref()) .map_err(|e| GitError::CommandFailed(e.to_string())) }) @@ -1380,8 +1475,7 @@ impl GitService { let repository_info = Self::resolve_worktree_repository(path.as_ref()).await?; task::spawn_blocking(move || { - let repository = Repository::discover(&repository_path) - .map_err(|error| GitError::RepositoryNotFound(error.to_string()))?; + let repository = discover_repository(&repository_path)?; match repository.head() { Ok(head) if head.target().is_some() => {} Err(error) if error.code() == ErrorCode::UnbornBranch => { @@ -1425,11 +1519,8 @@ impl GitService { let normalized_expected = worktree_path_str.replace("\\", "/"); let expected_branch = branch.to_string(); task::spawn_blocking(move || { - let repository = Repository::open(&worktree_path).map_err(|error| { - GitError::CommandFailed(format!( - "Failed to inspect newly created worktree: {error}" - )) - })?; + let repository = open_repository(&worktree_path) + .map_err(|error| error.with_context("Failed to inspect newly created worktree"))?; let (branch, head) = match repository.head() { Ok(head) => ( head.shorthand().ok().map(str::to_string), @@ -1529,7 +1620,10 @@ fn ensure_worktree_directory_excluded(common_git_dir: &Path) -> Result<(), GitEr #[cfg(test)] mod review_path_tests { - use super::{review_path_has_parent_traversal, GitLogParams, GitService}; + use super::{ + map_trust_probe_error, review_path_has_parent_traversal, GitError, GitLogParams, + GitService, GitTrustDetectionSource, + }; use std::{fs, path::Path, process::Command}; fn git(root: &Path, args: &[&str], commit_date: Option<&str>) { @@ -1577,6 +1671,91 @@ mod review_path_tests { assert!(review_path_has_parent_traversal(r"src\..\outside.rs", true,)); } + #[test] + fn probe_preserves_typed_owner_rejection() { + let error = map_trust_probe_error( + Path::new("repository"), + GitError::RepositoryTrustRequired { + requested_path: "repository".to_string(), + repository_path: "repository".to_string(), + operation: None, + detected_by: GitTrustDetectionSource::Libgit2Owner, + }, + ); + + assert!(matches!( + error, + GitError::RepositoryTrustRequired { + detected_by: GitTrustDetectionSource::Libgit2Owner, + .. + } + )); + } + + #[test] + fn probe_maps_non_owner_errors_to_unsupported_with_diagnostics() { + let error = map_trust_probe_error( + Path::new("repository"), + GitError::RepositoryNotFound("repository".to_string()), + ); + + assert!(matches!( + error, + GitError::TrustUnsupported { repository_path, reason } + if repository_path == "repository" && reason.contains("Repository not found") + )); + } + + #[test] + fn probe_maps_git2_errors_to_unsupported_with_diagnostics() { + let git2_error = git2::Error::new( + git2::ErrorCode::InvalidSpec, + git2::ErrorClass::Repository, + "probe failed", + ); + let error = map_trust_probe_error(Path::new("repository"), GitError::Git2Error(git2_error)); + + assert!(matches!( + error, + GitError::TrustUnsupported { reason, .. } if reason.contains("probe failed") + )); + } + + #[tokio::test] + async fn probe_preparation_maps_resolution_failures_to_unsupported() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let missing = directory.path().join("missing-repository"); + + let error = GitService::prepare_repository_trust_probe(&missing) + .await + .expect_err("missing probe roots must not be write failures"); + + assert!(matches!( + error, + GitError::TrustUnsupported { reason, .. } if reason.contains("does not exist") + )); + } + + #[tokio::test] + async fn successful_probe_is_terminal_unsupported_and_does_not_write_config() { + let directory = tempfile::tempdir().expect("temporary repository should be created"); + git(directory.path(), &["init"], None); + + let canonical = GitService::prepare_repository_trust_probe(directory.path()) + .await + .expect("probe root should resolve"); + let error = GitService::probe_repository_trust(&canonical) + .await + .expect_err("a successful confirmation probe must be terminal unsupported"); + + assert!(matches!( + error, + GitError::TrustUnsupported { reason, .. } + if reason.contains("read-only confirmation probe") + && reason.contains("did not change safe.directory") + )); + } + #[tokio::test] async fn commit_date_filters_use_git_approxidates_instead_of_revision_names() { let directory = tempfile::tempdir().expect("temporary repository should be created"); diff --git a/src/crates/services/services-integrations/src/git/types.rs b/src/crates/services/services-integrations/src/git/types.rs index edd384d0c..8d4508afa 100644 --- a/src/crates/services/services-integrations/src/git/types.rs +++ b/src/crates/services/services-integrations/src/git/types.rs @@ -170,6 +170,7 @@ pub struct GitDiffParams { pub files: Option>, pub staged: Option, pub stat: Option, + /// Selects the bounded, non-interactive path reserved for Review evidence. #[serde(default, alias = "reviewSafe")] pub review_safe: Option, } @@ -179,6 +180,7 @@ pub struct GitChangedFilesParams { pub source: Option, pub target: Option, pub staged: Option, + /// Selects the bounded, non-interactive path reserved for Review evidence. #[serde(default, alias = "reviewSafe")] pub review_safe: Option, } @@ -218,6 +220,31 @@ pub struct GitCommandOutput { pub exit_code: i32, } +/// Source of a Git repository trust failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GitTrustDetectionSource { + Libgit2Owner, + GitCliDubiousOwnership, +} + +/// Backends used to verify that a newly trusted worktree is usable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GitTrustVerificationBackend { + Libgit2, + GitCli, +} + +/// Result of adding one repository to the user's Git trust list. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitTrustResult { + pub repository_path: String, + pub already_trusted: bool, + pub verified_by: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitDiffResult { pub files: Vec, diff --git a/src/crates/services/services-integrations/src/git/utils.rs b/src/crates/services/services-integrations/src/git/utils.rs index fd75496a2..043902518 100644 --- a/src/crates/services/services-integrations/src/git/utils.rs +++ b/src/crates/services/services-integrations/src/git/utils.rs @@ -4,17 +4,170 @@ pub use super::{ /** * Git utility functions */ -use super::{GitCommandOutput, GitError, GitFileStatus}; +use super::{ + GitCommandOutput, GitError, GitFileStatus, GitTrustDetectionSource, GitTrustResult, + GitTrustVerificationBackend, +}; use bitfun_services_core::process_manager; use git2::{Repository, Status, StatusOptions}; -use std::path::Path; -use std::process::Stdio; +use std::path::{Path, PathBuf}; +use std::process::{Command as StdCommand, Stdio}; use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command as TokioCommand; use tokio::time::timeout; const REVIEW_GIT_TIMEOUT: Duration = Duration::from_secs(30); const REVIEW_GIT_OUTPUT_LIMIT: usize = 8 * 1024 * 1024; +const TRUST_CONFIG_RETRY_DELAYS_MS: [u64; 3] = [100, 200, 400]; + +pub(crate) fn apply_git_cli_env_tokio(command: &mut TokioCommand) { + command + .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C"); +} + +pub(crate) fn apply_git_cli_env_std(command: &mut StdCommand) { + command + .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C"); +} + +fn is_valid_git_marker(marker: &Path) -> bool { + if marker.is_dir() { + return marker.join("HEAD").is_file() && marker.join("config").is_file(); + } + + marker.is_file() + && std::fs::read_to_string(marker) + .map(|content| { + content + .lines() + .any(|line| line.trim_start().starts_with("gitdir:")) + }) + .unwrap_or(false) +} + +fn is_bare_git_repository_root(path: &Path) -> bool { + path.is_dir() + && path.join("HEAD").is_file() + && path.join("config").is_file() + && !path.join(".git").exists() +} + +fn canonicalize_path(path: &Path) -> std::io::Result { + Ok(normalize_canonical_path(std::fs::canonicalize(path)?)) +} + +fn normalize_canonical_path(canonical: PathBuf) -> PathBuf { + #[cfg(windows)] + { + let value = canonical.to_string_lossy(); + if let Some(unc_path) = value.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{unc_path}")); + } + if let Some(drive_path) = value.strip_prefix(r"\\?\") { + return PathBuf::from(drive_path); + } + } + + canonical +} + +fn canonical_repository_root_candidate(path: &Path) -> Option { + let start = if path.is_file() { + path.parent().unwrap_or(path) + } else { + path + }; + let canonical_start = canonicalize_path(start).ok()?; + + canonical_start + .ancestors() + .find(|ancestor| is_valid_git_marker(&ancestor.join(".git"))) + .map(Path::to_path_buf) +} + +fn canonical_bare_repository_candidate(path: &Path) -> Option { + let start = if path.is_file() { + path.parent().unwrap_or(path) + } else { + path + }; + let canonical_start = canonicalize_path(start).ok()?; + + canonical_start + .ancestors() + .find(|ancestor| is_bare_git_repository_root(ancestor)) + .map(Path::to_path_buf) +} + +fn classify_repository_trust_root( + requested_path: String, + repository_path: PathBuf, + operation: Option, + detected_by: GitTrustDetectionSource, +) -> GitError { + if let Some(reason) = public_trust_root_reason(&repository_path) { + return GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason, + }; + } + + GitError::RepositoryTrustRequired { + requested_path, + repository_path: repository_path.to_string_lossy().into_owned(), + operation, + detected_by, + } +} + +fn repository_trust_error( + requested_path: &Path, + error: git2::Error, + detected_by: GitTrustDetectionSource, +) -> GitError { + if error.code() == git2::ErrorCode::Owner { + let requested_path = requested_path.to_string_lossy().into_owned(); + if let Some(repository_path) = + canonical_bare_repository_candidate(Path::new(&requested_path)) + { + return GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason: "Bare repositories are not eligible for worktree trust authorization" + .to_string(), + }; + } + if let Some(repository_path) = + canonical_repository_root_candidate(Path::new(&requested_path)) + { + classify_repository_trust_root(requested_path, repository_path, None, detected_by) + } else { + GitError::InvalidPath(format!( + "Git reported owner validation failure, but the repository root could not be resolved: {error}" + )) + } + } else if error.code() == git2::ErrorCode::NotFound { + GitError::RepositoryNotFound(requested_path.to_string_lossy().into_owned()) + } else { + GitError::Git2Error(error) + } +} + +pub(crate) fn open_repository(path: impl AsRef) -> Result { + let path = path.as_ref(); + Repository::open(path) + .map_err(|error| repository_trust_error(path, error, GitTrustDetectionSource::Libgit2Owner)) +} + +pub(crate) fn discover_repository(path: impl AsRef) -> Result { + let path = path.as_ref(); + Repository::discover(path) + .map_err(|error| repository_trust_error(path, error, GitTrustDetectionSource::Libgit2Owner)) +} async fn read_bounded_review_git_stream(reader: R) -> Result, GitError> where @@ -35,8 +188,12 @@ where } /// Returns whether the given path is a Git repository. -pub fn is_git_repository>(path: P) -> bool { - Repository::open(path).is_ok() +pub fn is_git_repository>(path: P) -> Result { + match open_repository(path) { + Ok(_) => Ok(true), + Err(GitError::RepositoryNotFound(_)) => Ok(false), + Err(error) => Err(error), + } } /// Returns the repository root directory. @@ -51,13 +208,16 @@ pub fn get_repository_root>(path: P) -> Result .ancestors() .filter(|ancestor| ancestor.join(".git").exists()) { - if Repository::open(root).is_ok() { - return Ok(root.to_string_lossy().to_string()); + match open_repository(root) { + Ok(_) => { + return Ok(root.to_string_lossy().to_string()); + } + Err(error @ GitError::RepositoryTrustRequired { .. }) => return Err(error), + Err(_) => {} } } - let repo = - Repository::discover(requested).map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + let repo = discover_repository(requested)?; let workdir = repo .workdir() @@ -66,6 +226,425 @@ pub fn get_repository_root>(path: P) -> Result Ok(workdir.to_string_lossy().to_string()) } +fn git_command_output_message(output: &GitCommandOutput) -> String { + let stderr = output.stderr.trim(); + if stderr.is_empty() { + output.stdout.trim().to_string() + } else { + stderr.to_string() + } +} + +/// Classifies the stable Git CLI wording used for an unsafe repository. +/// +/// Git has no dedicated machine-readable exit code for this case, so all +/// callers must use this pure helper instead of matching arbitrary text at +/// individual call sites. +pub(crate) fn classify_git_command_failure( + repo_path: &str, + output: &GitCommandOutput, +) -> Option { + if output.exit_code == 0 { + return None; + } + + let message = format!("{}\n{}", output.stderr, output.stdout).to_ascii_lowercase(); + let has_owner_phrase = + message.contains("detected dubious ownership") || message.contains("unsafe repository"); + let has_safe_directory_hint = message.contains("safe.directory"); + + if !has_safe_directory_hint { + return None; + } + + if let Some(repository_path) = canonical_bare_repository_candidate(Path::new(repo_path)) { + return Some(GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason: "Bare repositories are not eligible for worktree trust authorization" + .to_string(), + }); + } + + if has_owner_phrase { + if let Some(repository_path) = canonical_repository_root_candidate(Path::new(repo_path)) { + return Some(classify_repository_trust_root( + repo_path.to_string(), + repository_path, + None, + GitTrustDetectionSource::GitCliDubiousOwnership, + )); + } + } + + // Preserve the typed uncertain signal even when the path cannot be + // canonicalized. The interactive layer may run one independent, + // read-only confirmation probe; collapsing this into CommandFailed would + // remove that recovery path and hide the diagnostic from the caller. + Some(GitError::TrustDetectionUncertain { + repository_path: repo_path.to_string(), + reason: + "Git mentioned safe.directory but did not provide a complete owner rejection signal" + .to_string(), + }) +} + +pub(crate) fn classify_or_wrap_git_command_failure( + repo_path: &str, + output: &GitCommandOutput, +) -> GitError { + classify_git_command_failure(repo_path, output) + .unwrap_or_else(|| GitError::CommandFailed(git_command_output_message(output))) +} + +fn canonical_repository_path(repo_path: &str) -> Result { + let requested = Path::new(repo_path); + if !requested.exists() { + return Err(GitError::TrustAddFailed { + repository_path: repo_path.to_string(), + reason: format!("Git repository path does not exist: {repo_path}"), + }); + } + + if let Some(repository_path) = canonical_bare_repository_candidate(requested) { + return Err(GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason: "Bare repositories are not eligible for worktree trust authorization" + .to_string(), + }); + } + + let repository_path = + canonical_repository_root_candidate(requested).ok_or_else(|| GitError::TrustAddFailed { + repository_path: repo_path.to_string(), + reason: "Failed to resolve and canonicalize the Git worktree root".to_string(), + })?; + + if let Some(reason) = public_trust_root_reason(&repository_path) { + return Err(GitError::TrustUnsupported { + repository_path: repository_path.to_string_lossy().into_owned(), + reason, + }); + } + + Ok(repository_path) +} + +/// Resolves the exact canonical worktree root that a trust probe or trust +/// write would use. +pub fn resolve_repository_trust_root(repo_path: &str) -> Result { + canonical_repository_path(repo_path) +} + +/// Performs the one fixed, read-only confirmation probe used by interactive +/// callers when the Git CLI wording was inconclusive. libgit2 exposes the +/// owner rejection as a typed error, so this probe can confirm the security +/// condition without writing configuration or replaying the original Git +/// operation. +pub fn probe_repository_trust(repo_path: &str) -> Result<(), GitError> { + let canonical_repository = canonical_repository_path(repo_path)?; + probe_repository_trust_at_canonical_root(&canonical_repository) +} + +/// Performs the read-only owner probe against a root that was canonicalized by +/// the caller. Keeping this separate prevents the probe from silently +/// following a replacement symlink between Desktop scope validation and the +/// actual owner check. +pub(crate) fn probe_repository_trust_at_canonical_root( + canonical_repository: &Path, +) -> Result<(), GitError> { + open_repository(canonical_repository).map(|_| ()) +} + +fn public_trust_root_reason(path: &Path) -> Option { + let canonical = canonicalize_path(path).ok()?; + if canonical.parent().is_none() { + return Some("Filesystem roots are not eligible for Git trust authorization".to_string()); + } + + let mut public_roots = vec![std::env::temp_dir()]; + + #[cfg(windows)] + { + for variable in [ + "PUBLIC", + "ProgramData", + "ProgramFiles", + "ProgramFiles(x86)", + "CommonProgramFiles", + "CommonProgramFiles(x86)", + "windir", + ] { + if let Some(root) = std::env::var_os(variable).map(PathBuf::from) { + if let Some(parent) = root.parent() { + public_roots.push(parent.to_path_buf()); + } + public_roots.push(root); + } + } + } + + #[cfg(unix)] + { + let unix_root = PathBuf::from(std::path::MAIN_SEPARATOR.to_string()); + public_roots.extend([ + unix_root.join("tmp"), + unix_root.join("var").join("tmp"), + unix_root.join("home"), + unix_root.join("Users"), + unix_root.join("Users").join("Shared"), + unix_root.join("mnt"), + unix_root.join("media"), + unix_root.join("opt"), + unix_root.join("srv"), + unix_root.join("usr").join("local"), + ]); + } + + public_roots + .into_iter() + .filter_map(|root| canonicalize_path(&root).ok()) + .find(|root| root == &canonical) + .map(|root| { + format!( + "Public filesystem root '{}' is not eligible for Git trust authorization", + root.to_string_lossy() + ) + }) +} + +fn safe_directory_config_value(canonical_repository: &Path) -> String { + let value = canonical_repository.to_string_lossy(); + + #[cfg(windows)] + { + // libgit2 converts its canonical Windows paths to POSIX separators + // before comparing them with safe.directory entries. Keep the value + // written by BitFun in that exact form; Git CLI also accepts it. + value.replace('\\', "/") + } + + #[cfg(not(windows))] + { + value.into_owned() + } +} + +fn safe_directory_matches(value: &str, canonical_repository: &Path) -> bool { + let configured = value.trim(); + if configured == "*" { + return false; + } + + #[cfg(windows)] + { + if configured.contains('\\') || configured.ends_with('/') { + return false; + } + configured == safe_directory_config_value(canonical_repository) + } + + #[cfg(not(windows))] + { + configured == safe_directory_config_value(canonical_repository) + } +} + +fn read_global_safe_directories(repo_path: &str) -> Result, GitError> { + let output = execute_git_command_sync_raw( + repo_path, + &["config", "--global", "--get-all", "safe.directory"], + )?; + + if output.exit_code == 0 { + return Ok(output + .stdout + .lines() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect()); + } + + // `git config --get-all` uses exit code 1 when the key is absent. + if output.exit_code == 1 { + return Ok(Vec::new()); + } + + Err(classify_or_wrap_git_command_failure(repo_path, &output)) +} + +fn is_git_config_lock_failure(output: &GitCommandOutput) -> bool { + let message = format!("{}\n{}", output.stderr, output.stdout).to_ascii_lowercase(); + message.contains("could not lock config file") + || (message.contains("unable to write config file") && message.contains("lock")) + || message.contains("config.lock") +} + +fn add_global_safe_directory_with_retry( + repo_path: &str, + canonical_repository: &str, +) -> Result { + add_global_safe_directory_with_retry_using( + repo_path, + canonical_repository, + execute_git_command_sync_raw, + |delay| std::thread::sleep(delay), + ) +} + +fn add_global_safe_directory_with_retry_using( + repo_path: &str, + canonical_repository: &str, + mut execute: Execute, + mut sleep: Sleep, +) -> Result +where + Execute: FnMut(&str, &[&str]) -> Result, + Sleep: FnMut(Duration), +{ + let args = [ + "config", + "--global", + "--add", + "safe.directory", + canonical_repository, + ]; + + for attempt in 0..=TRUST_CONFIG_RETRY_DELAYS_MS.len() { + let output = execute(repo_path, &args)?; + if output.exit_code == 0 + || !is_git_config_lock_failure(&output) + || attempt >= TRUST_CONFIG_RETRY_DELAYS_MS.len() + { + return Ok(output); + } + + sleep(Duration::from_millis(TRUST_CONFIG_RETRY_DELAYS_MS[attempt])); + } + + unreachable!("the bounded Git config write loop always returns") +} + +fn map_trust_operation_error(repository_path: &str, error: GitError) -> GitError { + match error { + GitError::CommandFailed(reason) if reason.contains("Failed to execute git command") => { + GitError::TrustUnsupported { + repository_path: repository_path.to_string(), + reason, + } + } + error @ (GitError::TrustUnsupported { .. } | GitError::TrustDetectionUncertain { .. }) => { + error + } + error => GitError::TrustAddFailed { + repository_path: repository_path.to_string(), + reason: error.to_string(), + }, + } +} + +/// Adds one canonical worktree root to the user's protected Git trust list. +/// +/// The caller must have obtained explicit user consent. This function still +/// validates the path because the UI is not a security boundary. +pub fn trust_repository(repo_path: &str) -> Result { + let canonical_repository = canonical_repository_path(repo_path)?; + trust_repository_at_canonical_path(canonical_repository) +} + +fn revalidate_canonical_repository_root(expected_repository: &Path) -> Result { + // `expected_repository` is the canonical root captured by the Desktop + // scope check. Do not canonicalize it here before comparing: doing so + // would follow a replacement symlink and erase the identity we need to + // protect at the write boundary. + let expected_repository = normalize_canonical_path(expected_repository.to_path_buf()); + let requested_path = expected_repository.to_string_lossy().into_owned(); + let canonical_repository = canonical_repository_path(&requested_path)?; + if canonical_repository != expected_repository { + return Err(GitError::TrustUnsupported { + repository_path: requested_path, + reason: "The canonical Git worktree root changed during trust authorization" + .to_string(), + }); + } + + Ok(canonical_repository) +} + +/// Adds trust only when the path still resolves to the canonical root that was +/// validated by the Desktop workspace boundary. +pub fn trust_repository_at_canonical_root( + expected_repository: &Path, +) -> Result { + let canonical_repository = revalidate_canonical_repository_root(expected_repository)?; + trust_repository_at_canonical_path(canonical_repository) +} + +fn trust_repository_at_canonical_path( + canonical_repository: PathBuf, +) -> Result { + let canonical_repository_string = canonical_repository.to_string_lossy().into_owned(); + let safe_directory_value = safe_directory_config_value(&canonical_repository); + let current_values = read_global_safe_directories(&canonical_repository_string) + .map_err(|error| map_trust_operation_error(&canonical_repository_string, error))?; + let already_trusted = current_values + .iter() + .any(|value| safe_directory_matches(value, &canonical_repository)); + + if !already_trusted { + let output = add_global_safe_directory_with_retry( + &canonical_repository_string, + &safe_directory_value, + ) + .map_err(|error| map_trust_operation_error(&canonical_repository_string, error))?; + + if output.exit_code != 0 { + return Err(GitError::TrustAddFailed { + repository_path: canonical_repository_string.clone(), + reason: git_command_output_message(&output), + }); + } + } + + let verified_values = read_global_safe_directories(&canonical_repository_string) + .map_err(|error| map_trust_operation_error(&canonical_repository_string, error))?; + if !verified_values + .iter() + .any(|value| safe_directory_matches(value, &canonical_repository)) + { + return Err(GitError::TrustAddFailed { + repository_path: canonical_repository_string.clone(), + reason: "Git did not report the canonical path in global safe.directory".to_string(), + }); + } + + open_repository(&canonical_repository).map_err(|error| GitError::TrustAddFailed { + repository_path: canonical_repository_string.clone(), + reason: error.to_string(), + })?; + + let cli_output = execute_git_command_sync_raw( + &canonical_repository_string, + &["rev-parse", "--is-inside-work-tree"], + ) + .map_err(|error| map_trust_operation_error(&canonical_repository_string, error))?; + if cli_output.exit_code != 0 || cli_output.stdout.trim() != "true" { + return Err(GitError::TrustAddFailed { + repository_path: canonical_repository_string.clone(), + reason: git_command_output_message(&cli_output), + }); + } + + Ok(GitTrustResult { + repository_path: canonical_repository_string, + already_trusted, + verified_by: vec![ + GitTrustVerificationBackend::Libgit2, + GitTrustVerificationBackend::GitCli, + ], + }) +} + /// Returns the current branch name. pub fn get_current_branch(repo: &Repository) -> Result { match repo.head() { @@ -254,7 +833,9 @@ pub async fn execute_git_command_raw( repo_path: &str, args: &[&str], ) -> Result { - let output = process_manager::create_tokio_command("git") + let mut command = process_manager::create_tokio_command("git"); + apply_git_cli_env_tokio(&mut command); + let output = command .current_dir(repo_path) .args(args) .output() @@ -279,12 +860,7 @@ pub async fn execute_git_command(repo_path: &str, args: &[&str]) -> Result Result { let mut command = process_manager::create_tokio_command("git"); + apply_git_cli_env_tokio(&mut command); command .current_dir(repo_path) .env("GIT_OPTIONAL_LOCKS", "0") @@ -345,16 +922,15 @@ pub async fn execute_git_readonly_command( } }; - if status.success() { - Ok(String::from_utf8_lossy(&stdout).to_string()) + let output = GitCommandOutput { + stdout: String::from_utf8_lossy(&stdout).to_string(), + stderr: String::from_utf8_lossy(&stderr).to_string(), + exit_code: status.code().unwrap_or(-1), + }; + if output.exit_code == 0 { + Ok(output.stdout) } else { - let stderr = String::from_utf8_lossy(&stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&stdout).trim().to_string(); - Err(GitError::CommandFailed(if stderr.is_empty() { - stdout - } else { - stderr - })) + Err(classify_or_wrap_git_command_failure(repo_path, &output)) } } @@ -363,7 +939,9 @@ pub fn execute_git_command_sync_raw( repo_path: &str, args: &[&str], ) -> Result { - let output = process_manager::create_command("git") + let mut command = process_manager::create_command("git"); + apply_git_cli_env_std(&mut command); + let output = command .current_dir(repo_path) .args(args) .output() @@ -391,7 +969,9 @@ pub fn execute_git_command_sync_with_timeout( ) -> Result { use std::process::Stdio; - let mut child = process_manager::create_command("git") + let mut command = process_manager::create_command("git"); + apply_git_cli_env_std(&mut command); + let mut child = command .current_dir(repo_path) .args(args) .stdin(Stdio::null()) @@ -427,16 +1007,19 @@ pub fn execute_git_command_sync_with_timeout( let output = child .wait_with_output() .map_err(|e| GitError::CommandFailed(format!("Failed to read git output: {}", e)))?; - if output.status.success() { - return Ok(String::from_utf8_lossy(&output.stdout).to_string()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Err(GitError::CommandFailed(if stderr.is_empty() { - stdout + let command_output = GitCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + }; + if command_output.exit_code == 0 { + Ok(command_output.stdout) } else { - stderr - })) + Err(classify_or_wrap_git_command_failure( + repo_path, + &command_output, + )) + } } /// Executes a Git command synchronously. @@ -446,12 +1029,7 @@ pub fn execute_git_command_sync(repo_path: &str, args: &[&str]) -> Result - {/* Session usage report. Mounted here rather than in a chat view: +{/* Session usage report. Mounted here rather than in a chat view: the request runs below any component, and the report outlives whichever session view is on screen. */} + {/* Local Git repository trust recovery */} + + {/* Announcement / feature-demo / tips system */} diff --git a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx index 4bd355df7..998586d17 100644 --- a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx @@ -117,7 +117,9 @@ export const BranchQuickSwitch: React.FC = ({ lastCommit: b.lastCommit, ahead: b.ahead, behind: b.behind, }))); setIsLoading(false); - gitStateManager.refresh(repositoryPath, { layers: ['detailed'], silent: true }); + void gitStateManager.refresh(repositoryPath, { layers: ['detailed'], silent: true }).catch((error) => { + log.debug('Background branch refresh failed', { repositoryPath, error }); + }); return; } await gitStateManager.refresh(repositoryPath, { layers: ['detailed'], force: true }); diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index 040284991..a846a05b1 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -643,6 +643,7 @@ const FlexiblePanel: React.FC = memo(({ ); @@ -655,6 +656,7 @@ const FlexiblePanel: React.FC = memo(({ branchName={content.data?.branchName || 'main'} currentBranch={content.data?.currentBranch} maxCount={content.data?.maxCount || 100} + interactionMode="interactive" /> ); diff --git a/src/web-ui/src/app/scenes/git/GitNav.tsx b/src/web-ui/src/app/scenes/git/GitNav.tsx index 9e7859dfd..614299e19 100644 --- a/src/web-ui/src/app/scenes/git/GitNav.tsx +++ b/src/web-ui/src/app/scenes/git/GitNav.tsx @@ -52,6 +52,14 @@ const GitNav: React.FC = () => { [setActiveView] ); + const handleRefresh = useCallback(async () => { + try { + await refresh({ force: true }); + } catch { + // GitStateManager stores refresh failures in the repository state. + } + }, [refresh]); + return (
@@ -81,7 +89,7 @@ const GitNav: React.FC = () => {
)}
- refresh({ force: true })} tooltip={t('actions.refresh')}> + { void handleRefresh(); }} tooltip={t('actions.refresh')}>
diff --git a/src/web-ui/src/app/scenes/git/GitScene.scss b/src/web-ui/src/app/scenes/git/GitScene.scss index 6f81b7928..74d10d74c 100644 --- a/src/web-ui/src/app/scenes/git/GitScene.scss +++ b/src/web-ui/src/app/scenes/git/GitScene.scss @@ -10,7 +10,8 @@ overflow: hidden; &--not-repository, - &--loading { + &--loading, + &--trust-required { .bitfun-git-scene__content { display: flex; flex-direction: column; @@ -86,6 +87,10 @@ } } + &__trust-spinner { + animation: bitfun-git-scene-trust-spin 1s linear infinite; + } + &__init-button { display: inline-flex; align-items: center; @@ -142,3 +147,8 @@ opacity: 0.6; } } + +@keyframes bitfun-git-scene-trust-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/web-ui/src/app/scenes/git/GitScene.tsx b/src/web-ui/src/app/scenes/git/GitScene.tsx index 558f6b2d4..ea9c3fc98 100644 --- a/src/web-ui/src/app/scenes/git/GitScene.tsx +++ b/src/web-ui/src/app/scenes/git/GitScene.tsx @@ -9,9 +9,12 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { useGitSceneStore } from './gitSceneStore'; import { WorkingCopyView, BranchesView, GraphView } from './views'; import { useGitState } from '@/tools/git/hooks'; +import { presentGitTrustOutcome } from '@/tools/git/GitTrustOutcomePresenter'; +import { gitAPI } from '@/infrastructure/api'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { IconButton, CubeLoading } from '@/component-library'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { createLogger } from '@/shared/utils/logger'; import './GitScene.scss'; interface GitSceneProps { @@ -19,6 +22,8 @@ interface GitSceneProps { isActive?: boolean; } +const log = createLogger('GitScene'); + const GitScene: React.FC = ({ workspacePath: workspacePathProp, isActive = true, @@ -29,10 +34,15 @@ const GitScene: React.FC = ({ const activeView = useGitSceneStore((s) => s.activeView); const [forceReset, setForceReset] = useState(false); + const [authorizingTrust, setAuthorizingTrust] = useState(false); const loadingTimeoutRef = useRef(null); + const forceRefreshTimeoutRef = useRef(null); const { isRepository, + trustRequired, + trustErrorCode, + trustErrorData, isLoading: statusLoading, refresh, } = useGitState({ @@ -42,19 +52,49 @@ const GitScene: React.FC = ({ layers: ['basic', 'status'], }); - const repoLoading = statusLoading && !isRepository; - const handleRefresh = useCallback( - () => refresh({ force: true, layers: ['basic', 'status'], reason: 'manual' }), - [refresh] - ); + const repoLoading = statusLoading && !isRepository && !trustRequired; + const inlineTrustOutcome = trustErrorCode + ? presentGitTrustOutcome( + { + code: trustErrorCode, + message: 'Git trust outcome', + data: trustErrorData ?? undefined, + }, + t, + workspacePath, + ) + : undefined; + const handleRefresh = useCallback(async () => { + try { + await refresh({ force: true, layers: ['basic', 'status'], reason: 'manual' }); + } catch { + // GitStateManager stores refresh failures in the repository state. + } + }, [refresh]); + + const handleAuthorize = useCallback(async () => { + if (!workspacePath || authorizingTrust) return; + + setAuthorizingTrust(true); + try { + await gitAPI.getStatus(workspacePath, 'git_scene_trust_authorize', 'interactive'); + await handleRefresh(); + } catch (error) { + log.warn('Git repository trust authorization was not completed', { workspacePath, error }); + } finally { + setAuthorizingTrust(false); + } + }, [authorizingTrust, handleRefresh, workspacePath]); useEffect(() => { if (repoLoading || statusLoading) { loadingTimeoutRef.current = setTimeout(() => { setForceReset(true); - setTimeout(() => { + if (forceRefreshTimeoutRef.current) clearTimeout(forceRefreshTimeoutRef.current); + forceRefreshTimeoutRef.current = setTimeout(() => { + forceRefreshTimeoutRef.current = null; setForceReset(false); - handleRefresh(); + void handleRefresh(); }, 100); }, 10000); } else { @@ -64,7 +104,14 @@ const GitScene: React.FC = ({ } } return () => { - if (loadingTimeoutRef.current) clearTimeout(loadingTimeoutRef.current); + if (loadingTimeoutRef.current) { + clearTimeout(loadingTimeoutRef.current); + loadingTimeoutRef.current = null; + } + if (forceRefreshTimeoutRef.current) { + clearTimeout(forceRefreshTimeoutRef.current); + forceRefreshTimeoutRef.current = null; + } }; }, [repoLoading, statusLoading, handleRefresh]); @@ -88,6 +135,52 @@ const GitScene: React.FC = ({ return