From f1ec48dfa36df05eccf9d99def5828226e5dd299 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 21 May 2026 01:54:51 +0100 Subject: [PATCH 1/6] feat(errors): interpret git errors with useful advice/options to solve the issues --- src-tauri/src/commands/repo.rs | 3 + src-tauri/src/commands/settings.rs | 5 + src-tauri/src/git/cli.rs | 168 ++++--- src-tauri/src/git/error.rs | 45 +- src-tauri/src/git/error_interpretation.rs | 444 +++++++++++++++++++ src-tauri/src/git/gix_handler.rs | 106 +++-- src-tauri/src/git/mod.rs | 2 +- src-tauri/src/git/types.rs | 12 + src-tauri/src/lib.rs | 67 ++- src/components/ProjectView.tsx | 18 +- src/components/centre/PushRejectedDialog.css | 22 + src/components/centre/PushRejectedDialog.tsx | 6 +- src/components/icons.tsx | 2 + src/i18n/index.ts | 3 + src/i18n/locales/en/gitAdvice.json | 19 + src/types.ts | 33 +- 16 files changed, 809 insertions(+), 146 deletions(-) create mode 100644 src-tauri/src/git/error_interpretation.rs create mode 100644 src/i18n/locales/en/gitAdvice.json diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index bdf7e9f..23de5e1 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -83,6 +83,7 @@ pub fn init_repo(repo_path: String) -> Result { output: None, repo_path: Some(path.to_string_lossy().to_string()), backend_used: "git-cli".to_string(), + interpreted_error: None, }); } @@ -117,6 +118,7 @@ pub fn init_repo(repo_path: String) -> Result { output: None, repo_path: Some(path.to_string_lossy().to_string()), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -232,6 +234,7 @@ pub async fn clone_repo( output: None, repo_path: Some(final_dest_str), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 2841b69..1ba961b 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -769,6 +769,7 @@ pub fn set_global_diff_tool( output: None, repo_path: None, backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -790,6 +791,7 @@ pub fn set_global_default_branch(default_branch: String) -> Result Result output: None, repo_path: None, backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -955,6 +959,7 @@ pub fn set_global_gpg_program(gpg_program: String) -> Result PushRejectionAnalysis { - let stderr_lower = stderr.to_ascii_lowercase(); let (current_branch, upstream_branch) = Self::push_failure_branch_context(repo_path); - - let (kind, message, suggested_next_actions) = if stderr_lower.contains("non-fast-forward") - || stderr_lower.contains("[rejected]") - || stderr_lower.contains("fetch first") - || stderr_lower.contains("tip of your current branch is behind") - { - ( - PushFailureKind::NonFastForward, - "Push was rejected because the remote branch has new commits. Fetch, review, and integrate those changes before pushing again.".to_string(), - vec![ - "fetch".to_string(), - "review".to_string(), - "integrate".to_string(), - ], - ) - } else if stderr_lower.contains("no upstream branch") { - ( - PushFailureKind::NoUpstream, - "This branch does not have an upstream yet. Publish it to a remote before pushing normally.".to_string(), - vec!["publish".to_string()], - ) - } else if stderr_lower.contains("upstream branch of your current branch does not match") - || stderr_lower.contains("has no such ref was fetched") - || stderr_lower.contains("couldn't find remote ref") - || stderr_lower.contains("upstream is gone") - || stderr_lower.contains("remote branch") - && (stderr_lower.contains("not found") - || stderr_lower.contains("does not exist") - || stderr_lower.contains("missing")) - { - ( - PushFailureKind::UpstreamMissing, - "The configured upstream branch is missing or no longer matches this branch. Repair the upstream before retrying.".to_string(), - vec!["repair-upstream".to_string()], - ) - } else if stderr_lower.contains("authentication failed") - || stderr_lower.contains("could not read from remote repository") - || stderr_lower.contains("permission denied") - || stderr_lower.contains("permission to") - || stderr_lower.contains("repository not found") - { - ( - PushFailureKind::Auth, - "Push failed because Git could not authenticate with the remote.".to_string(), - vec!["retry".to_string()], - ) - } else if stderr_lower.contains("could not resolve host") - || stderr_lower.contains("failed to connect") - || stderr_lower.contains("connection timed out") - || stderr_lower.contains("network is unreachable") - || stderr_lower.contains("connection reset") - { - ( - PushFailureKind::Network, - "Push failed because Git could not reach the remote.".to_string(), - vec!["retry".to_string()], - ) + let interpreted = Self::interpret_push_failure(stderr, None); + let kind = Self::push_failure_kind(interpreted.category); + let suggested_next_actions = if matches!(kind, PushFailureKind::NoUpstream) { + vec!["publish".to_string()] } else { - ( - PushFailureKind::Other, - "Push failed. Review the Git output and retry when the repository state is clear." - .to_string(), - vec!["retry".to_string()], - ) + interpreted.suggested_actions }; PushRejectionAnalysis { @@ -901,11 +847,26 @@ impl CliGitHandler { current_branch, upstream_branch, kind, - message, + message: interpreted.summary, suggested_next_actions, } } + fn interpret_push_failure(stderr: &str, exit_code: Option) -> InterpretedGitError { + interpret_cli_error(Some("push"), stderr, exit_code) + } + + fn push_failure_kind(category: GitErrorCategory) -> PushFailureKind { + match category { + GitErrorCategory::NonFastForward => PushFailureKind::NonFastForward, + GitErrorCategory::NoUpstream => PushFailureKind::NoUpstream, + GitErrorCategory::UpstreamMissing => PushFailureKind::UpstreamMissing, + GitErrorCategory::Auth => PushFailureKind::Auth, + GitErrorCategory::Network => PushFailureKind::Network, + _ => PushFailureKind::Other, + } + } + fn execute_pull_command( &self, repo_path: &Path, @@ -919,6 +880,7 @@ impl CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }), Err(GitError::CommandFailed { stderr, .. }) => { let status = self.get_repo_status(&Self::repo_request(repo_path))?; @@ -928,12 +890,14 @@ impl CliGitHandler { output: (!stderr.is_empty()).then_some(stderr), repo_path: Some(Self::path_to_string(repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }); } Err(GitError::CommandFailed { command: format!("git {}", args.join(" ")), stderr, + exit_code: None, }) } Err(error) => Err(error), @@ -1177,6 +1141,7 @@ impl CliGitHandler { return Err(GitError::CommandFailed { command: joined, stderr, + exit_code: output.status.code(), }); } @@ -1217,6 +1182,7 @@ impl CliGitHandler { return Err(GitError::CommandFailed { command: joined, stderr, + exit_code: output.status.code(), }); } @@ -1382,6 +1348,7 @@ impl CliGitHandler { return Err(GitError::CommandFailed { command: format!("open {}", path.display()), stderr, + exit_code: output.status.code(), }); } @@ -1648,6 +1615,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&resolved_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1689,6 +1657,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(final_destination_str), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1815,6 +1784,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1842,6 +1812,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1876,6 +1847,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }); } @@ -1904,6 +1876,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1967,6 +1940,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2079,9 +2053,10 @@ impl GitOperationHandler for CliGitHandler { let output = match Self::run_git(args.as_slice(), Some(&repo_path)) { Ok(stdout) => stdout, - Err(GitError::CommandFailed { command: _, stderr }) - if stderr.contains("does not have any commits yet") - || stderr.contains("appears to be broken") => + Err(GitError::CommandFailed { + command: _, stderr, .. + }) if stderr.contains("does not have any commits yet") + || stderr.contains("appears to be broken") => { return Ok(Vec::new()); } @@ -2287,7 +2262,9 @@ impl GitOperationHandler for CliGitHandler { let output = match Self::run_git(&args, Some(&repo_path)) { Ok(stdout) => stdout, - Err(GitError::CommandFailed { command: _, stderr }) if stderr.is_empty() => { + Err(GitError::CommandFailed { + command: _, stderr, .. + }) if stderr.is_empty() => { // Empty stderr + non-zero exit: no tracked changes. Fall through to check // whether this is an untracked new file that we can diff with --no-index. String::new() @@ -2449,6 +2426,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2465,6 +2443,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2477,6 +2456,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2517,6 +2497,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2553,6 +2534,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2604,6 +2586,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2749,6 +2732,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2779,6 +2763,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2831,6 +2816,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2843,6 +2829,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2855,6 +2842,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2965,6 +2953,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2980,7 +2969,9 @@ impl GitOperationHandler for CliGitHandler { Some(&repo_path), ) { Ok(stdout) => stdout, - Err(GitError::CommandFailed { command: _, stderr }) if stderr.is_empty() => { + Err(GitError::CommandFailed { + command: _, stderr, .. + }) if stderr.is_empty() => { return Ok(Vec::new()); } Err(error) => return Err(error), @@ -3120,16 +3111,21 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: true, rejection: None, }), - Err(GitError::CommandFailed { stderr, .. }) => { + Err(GitError::CommandFailed { + stderr, exit_code, .. + }) => { + let interpreted = Self::interpret_push_failure(&stderr, exit_code); let rejection = Self::classify_push_failure(&repo_path, &stderr); Ok(PushResult { message: rejection.message.clone(), output: (!stderr.is_empty()).then_some(stderr), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: Some(interpreted), success: false, rejection: Some(rejection), }) @@ -3161,6 +3157,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3213,6 +3210,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3298,6 +3296,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3325,6 +3324,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3388,6 +3388,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3404,6 +3405,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3459,6 +3461,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3477,6 +3480,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3495,6 +3499,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3532,6 +3537,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3552,6 +3558,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3565,6 +3572,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3580,6 +3588,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3600,6 +3609,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3613,6 +3623,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3675,6 +3686,7 @@ impl GitOperationHandler for CliGitHandler { output: Some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3705,6 +3717,7 @@ impl GitOperationHandler for CliGitHandler { }, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3741,6 +3754,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3783,6 +3797,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3803,6 +3818,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3840,6 +3856,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3882,6 +3899,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3902,6 +3920,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3941,6 +3960,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3983,6 +4003,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -4003,6 +4024,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4030,6 +4052,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4048,6 +4071,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4066,6 +4090,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4111,6 +4136,7 @@ impl GitOperationHandler for CliGitHandler { }, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index 0a033d1..eb98f28 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -1,12 +1,21 @@ use std::fmt::{Display, Formatter}; +use super::error_interpretation::{interpret_cli_error, interpret_gix_error, InterpretedGitError}; + #[derive(Debug)] pub enum GitError { InvalidInput(String), GitUnavailable, - CommandFailed { command: String, stderr: String }, + CommandFailed { + command: String, + stderr: String, + exit_code: Option, + }, IoError(String), - GixError(String), + GixError { + message: String, + interpreted: Option, + }, } impl Display for GitError { @@ -14,15 +23,39 @@ impl Display for GitError { match self { Self::InvalidInput(message) => write!(f, "Invalid input: {message}"), Self::GitUnavailable => write!(f, "Git executable was not found on PATH"), - Self::CommandFailed { command, stderr } => { + Self::CommandFailed { + command, + stderr, + exit_code, + } => { + let operation = command.split_whitespace().nth(1); + let interpreted = interpret_cli_error(operation, stderr, *exit_code); if stderr.is_empty() { - write!(f, "Git command failed: {command}") + write!(f, "{}\nGit command failed: {command}", interpreted.summary) } else { - write!(f, "Git command failed: {command}\n{stderr}") + write!( + f, + "{}\nGit command failed: {command}\n{stderr}", + interpreted.summary + ) } } Self::IoError(message) => write!(f, "I/O error: {message}"), - Self::GixError(message) => write!(f, "gix error: {message}"), + Self::GixError { + message, + interpreted, + } => { + let fallback; + let interpreted = match interpreted { + Some(interpreted) => interpreted, + None => { + fallback = + interpret_gix_error(None, &std::io::Error::other(message.clone())); + &fallback + } + }; + write!(f, "{}\ngix error: {message}", interpreted.summary) + } } } } diff --git a/src-tauri/src/git/error_interpretation.rs b/src-tauri/src/git/error_interpretation.rs new file mode 100644 index 0000000..22a51b5 --- /dev/null +++ b/src-tauri/src/git/error_interpretation.rs @@ -0,0 +1,444 @@ +use std::any::type_name; +use std::error::Error; + +use serde::Serialize; + +/// Turns Git errors into short explanations and next-step action IDs. +/// +/// When adding a new case, put the clearest match before looser matches, choose +/// the closest shared category, and add any new action IDs in `advice_for`. +/// Use higher confidence for exact Git messages, medium confidence for broad +/// gix error families, and low confidence for plain text guesses. Unknown +/// errors should stay as `Other` and keep the raw Git message. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GitErrorCategory { + Auth, + Network, + NonFastForward, + NoUpstream, + UpstreamMissing, + ConflictInProgress, + IndexLock, + RepoState, + InvalidInput, + ToolUnavailable, + Permission, + Other, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GitBackendSource { + GitCli, + Gix, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InterpretedGitError { + pub category: GitErrorCategory, + pub summary: String, + pub suggested_actions: Vec, + pub confidence: f32, + pub backend: GitBackendSource, + pub raw_message: String, + pub operation: Option, +} + +#[derive(Debug, Clone, Copy)] +struct Advice { + summary: &'static str, + actions: &'static [&'static str], +} + +fn advice_for(category: GitErrorCategory, operation: Option<&str>) -> Advice { + match (category, operation) { + (GitErrorCategory::NonFastForward, Some("push")) => Advice { + summary: "Push was rejected because the remote branch has new commits.", + actions: &["fetch", "review", "integrate"], + }, + (GitErrorCategory::NonFastForward, _) => Advice { + summary: "The remote branch has new commits that are not present locally.", + actions: &["fetch", "review", "integrate"], + }, + (GitErrorCategory::NoUpstream, Some("push")) => Advice { + summary: "This branch does not have an upstream yet.", + actions: &["set-upstream"], + }, + (GitErrorCategory::NoUpstream, _) => Advice { + summary: "This branch does not have an upstream configured.", + actions: &["set-upstream"], + }, + (GitErrorCategory::UpstreamMissing, _) => Advice { + summary: "The configured upstream branch is missing or no longer matches this branch.", + actions: &["fetch", "repair-upstream"], + }, + (GitErrorCategory::Auth, _) => Advice { + summary: "Git could not authenticate with the remote.", + actions: &["fix-auth-ssh", "fix-auth-https", "retry"], + }, + (GitErrorCategory::Network, _) => Advice { + summary: "Git could not reach the remote.", + actions: &["check-network", "retry"], + }, + (GitErrorCategory::ConflictInProgress, _) => Advice { + summary: "A merge, rebase, cherry-pick, or revert needs attention first.", + actions: &["resolve-conflicts", "continue-sequencer", "abort-sequencer"], + }, + (GitErrorCategory::IndexLock, _) => Advice { + summary: "Git could not lock the repository index.", + actions: &["unlock-index", "retry"], + }, + (GitErrorCategory::RepoState, _) => Advice { + summary: "The repository state blocks this operation.", + actions: &["review"], + }, + (GitErrorCategory::InvalidInput, _) => Advice { + summary: "Git rejected the supplied input.", + actions: &["review"], + }, + (GitErrorCategory::ToolUnavailable, _) => Advice { + summary: "The Git executable or a required Git tool is unavailable.", + actions: &["open-settings-git-executable"], + }, + (GitErrorCategory::Permission, _) => Advice { + summary: "Git does not have permission to read or write a required path.", + actions: &["review", "retry"], + }, + (GitErrorCategory::Other, _) => Advice { + summary: "Git failed before the operation could complete.", + actions: &["review", "retry"], + }, + } +} + +fn build_interpretation( + category: GitErrorCategory, + backend: GitBackendSource, + operation: Option<&str>, + raw_message: &str, + confidence: f32, +) -> InterpretedGitError { + let advice = advice_for(category, operation); + InterpretedGitError { + category, + summary: advice.summary.to_string(), + suggested_actions: advice + .actions + .iter() + .map(|action| action.to_string()) + .collect(), + confidence: confidence.clamp(0.0, 1.0), + backend, + raw_message: raw_message.to_string(), + operation: operation.map(str::to_string), + } +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + +fn classify_message(message: &str) -> (GitErrorCategory, f32) { + let lower = message.to_ascii_lowercase(); + + if contains_any( + &lower, + &[ + "non-fast-forward", + "fetch first", + "tip of your current branch is behind", + ], + ) || (lower.contains("[rejected]") && lower.contains("push")) + { + return (GitErrorCategory::NonFastForward, 0.95); + } + + if lower.contains("no upstream branch") { + return (GitErrorCategory::NoUpstream, 0.95); + } + + if contains_any( + &lower, + &[ + "upstream branch of your current branch does not match", + "has no such ref was fetched", + "couldn't find remote ref", + "could not find remote ref", + "upstream is gone", + "remote ref does not exist", + ], + ) || (lower.contains("remote branch") + && contains_any(&lower, &["not found", "does not exist", "missing"])) + { + return (GitErrorCategory::UpstreamMissing, 0.9); + } + + if contains_any( + &lower, + &[ + "authentication failed", + "could not read from remote repository", + "permission denied (publickey)", + "permission to ", + "repository not found", + "terminal prompts disabled", + "could not read username", + "could not read password", + "invalid username or password", + ], + ) { + return (GitErrorCategory::Auth, 0.9); + } + + if contains_any( + &lower, + &[ + "could not resolve host", + "failed to connect", + "connection timed out", + "network is unreachable", + "connection reset", + "operation timed out", + "unable to access", + "couldn't connect to server", + ], + ) { + return (GitErrorCategory::Network, 0.85); + } + + if contains_any( + &lower, + &[ + "you have unmerged paths", + "fix conflicts and then commit the result", + "resolve all conflicts manually", + "merge is in progress", + "rebase in progress", + "cherry-pick is already in progress", + "revert is already in progress", + "you need to resolve your current index first", + ], + ) { + return (GitErrorCategory::ConflictInProgress, 0.9); + } + + if contains_any( + &lower, + &[ + "index.lock", + "unable to create", + "could not lock config file", + "cannot lock ref", + "failed to lock", + "lock file already exists", + ], + ) { + return (GitErrorCategory::IndexLock, 0.85); + } + + if contains_any( + &lower, + &[ + "not a git repository", + "bad revision", + "ambiguous argument", + "needed a single revision", + "your local changes would be overwritten", + "please commit your changes or stash them", + ], + ) { + return (GitErrorCategory::RepoState, 0.75); + } + + if contains_any( + &lower, + &[ + "permission denied", + "access is denied", + "read-only file system", + "operation not permitted", + ], + ) { + return (GitErrorCategory::Permission, 0.75); + } + + (GitErrorCategory::Other, 0.2) +} + +pub fn interpret_cli_error( + operation: Option<&str>, + stderr: &str, + exit_code: Option, +) -> InterpretedGitError { + let raw_message = match exit_code { + Some(code) if stderr.trim().is_empty() => format!("Git exited with status {code}."), + Some(code) => format!("{stderr}\n(exit status {code})"), + None => stderr.to_string(), + }; + let (category, confidence) = classify_message(stderr); + build_interpretation( + category, + GitBackendSource::GitCli, + operation, + raw_message.trim(), + confidence, + ) +} + +pub fn interpret_gix_error(operation: Option<&str>, err: &E) -> InterpretedGitError +where + E: Error + 'static + ?Sized, +{ + let error_type = type_name::(); + let message = err.to_string(); + let lower_type = error_type.to_ascii_lowercase(); + + let typed = if contains_any(&lower_type, &["transport", "connect", "protocol"]) { + Some((GitErrorCategory::Network, 0.8)) + } else if contains_any(&lower_type, &["auth", "credential"]) { + Some((GitErrorCategory::Auth, 0.8)) + } else if contains_any(&lower_type, &["lock", "transaction"]) { + Some((GitErrorCategory::IndexLock, 0.8)) + } else if contains_any(&lower_type, &["config"]) { + Some((GitErrorCategory::RepoState, 0.7)) + } else if contains_any(&lower_type, &["reference", "object", "revision", "commit"]) { + Some((GitErrorCategory::RepoState, 0.7)) + } else { + None + }; + + let (category, confidence) = typed.unwrap_or_else(|| { + let (category, confidence) = classify_message(&message); + (category, (confidence - 0.15).max(0.1)) + }); + + build_interpretation( + category, + GitBackendSource::Gix, + operation, + &message, + confidence, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct FakeTransportError; + + impl std::fmt::Display for FakeTransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "handshake failed") + } + } + + impl Error for FakeTransportError {} + + #[test] + fn cli_non_fast_forward_wins_before_broader_rejected_text() { + let interpreted = interpret_cli_error( + Some("push"), + "! [rejected] main -> main (fetch first)\nfatal: authentication failed", + Some(1), + ); + + assert_eq!(interpreted.category, GitErrorCategory::NonFastForward); + assert_eq!( + interpreted.suggested_actions, + vec!["fetch", "review", "integrate"] + ); + assert!(interpreted.confidence >= 0.9); + } + + #[test] + fn cli_no_upstream_maps_to_set_upstream_action() { + let interpreted = interpret_cli_error( + Some("push"), + "fatal: The current branch feature has no upstream branch.", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::NoUpstream); + assert_eq!(interpreted.suggested_actions, vec!["set-upstream"]); + } + + #[test] + fn cli_auth_and_network_are_distinct() { + let auth = interpret_cli_error( + Some("fetch"), + "Permission denied (publickey). Could not read from remote repository.", + Some(128), + ); + let network = interpret_cli_error( + Some("fetch"), + "fatal: unable to access 'https://example.test/repo.git/': Could not resolve host: example.test", + Some(128), + ); + + assert_eq!(auth.category, GitErrorCategory::Auth); + assert_eq!(network.category, GitErrorCategory::Network); + } + + #[test] + fn cli_conflict_state_maps_to_sequencer_actions() { + let interpreted = interpret_cli_error( + Some("pull"), + "error: you have unmerged paths\nfix conflicts and then commit the result", + Some(1), + ); + + assert_eq!(interpreted.category, GitErrorCategory::ConflictInProgress); + assert!( + interpreted + .suggested_actions + .contains(&"resolve-conflicts".to_string()) + ); + } + + #[test] + fn cli_index_lock_maps_to_unlock_action() { + let interpreted = interpret_cli_error( + Some("commit"), + "fatal: Unable to create '/repo/.git/index.lock': File exists.", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::IndexLock); + assert!( + interpreted + .suggested_actions + .contains(&"unlock-index".to_string()) + ); + } + + #[test] + fn cli_unknown_falls_back_to_other_with_low_confidence() { + let interpreted = + interpret_cli_error(Some("status"), "fatal: unexpected frobnication", Some(2)); + + assert_eq!(interpreted.category, GitErrorCategory::Other); + assert!(interpreted.confidence < 0.5); + } + + #[test] + fn gix_typed_transport_family_maps_to_network() { + let err = FakeTransportError; + let interpreted = interpret_gix_error(Some("fetch"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::Network); + assert_eq!(interpreted.backend, GitBackendSource::Gix); + } + + #[test] + fn gix_opaque_message_uses_lower_confidence_heuristics() { + let err = std::io::Error::other("could not lock config file"); + let interpreted = interpret_gix_error(Some("commit"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::IndexLock); + assert!(interpreted.confidence < 0.85); + } +} diff --git a/src-tauri/src/git/gix_handler.rs b/src-tauri/src/git/gix_handler.rs index 66b6451..c8d775e 100644 --- a/src-tauri/src/git/gix_handler.rs +++ b/src-tauri/src/git/gix_handler.rs @@ -4,6 +4,7 @@ use std::{collections::HashMap, collections::HashSet}; use super::cli::CliGitHandler; use super::error::{GitError, GitResult}; +use super::error_interpretation::interpret_gix_error; use super::handler::GitOperationHandler; use super::types::{ AddRemoteRequest, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, CloneRequest, @@ -35,13 +36,13 @@ impl GixGitHandler { fn validate_repo_with_gix(&self, repo_path: &str) -> GitResult<()> { let path = Path::new(repo_path.trim()); - gix::discover(path).map_err(|error| GitError::GixError(error.to_string()))?; + gix::discover(path).map_err(|error| Self::gix_error(None, error))?; Ok(()) } fn discover_repo_root(&self, repo_path: &str) -> GitResult { let path = Path::new(repo_path.trim()); - let repo = gix::discover(path).map_err(|error| GitError::GixError(error.to_string()))?; + let repo = gix::discover(path).map_err(|error| Self::gix_error(None, error))?; let root = repo.workdir().unwrap_or(repo.path()); Ok(root.to_string_lossy().to_string()) } @@ -60,6 +61,17 @@ impl GixGitHandler { String::from_utf8_lossy(value.as_ref()).to_string() } + fn gix_error(operation: Option<&str>, error: E) -> GitError + where + E: std::error::Error + 'static, + { + let interpreted = interpret_gix_error(operation, &error); + GitError::GixError { + message: error.to_string(), + interpreted: Some(interpreted), + } + } + fn status_from_worktree_summary( summary: gix::status::index_worktree::iter::Summary, ) -> &'static str { @@ -111,16 +123,19 @@ impl GixGitHandler { let config = repo.config_snapshot(); let refs = repo .references() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let mut branches = Vec::new(); let local_iter = refs .local_branches() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; for reference in local_iter { - let reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let short_name = Self::bstr_to_string(reference.name().shorten()); let is_current = current_branch_name.as_deref() == Some(short_name.as_str()); @@ -191,10 +206,13 @@ impl GixGitHandler { let remote_iter = refs .remote_branches() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; for reference in remote_iter { - let reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let short_name = Self::bstr_to_string(reference.name().shorten()); // Skip symbolic HEAD pointers like "origin/HEAD" if short_name.ends_with("/HEAD") { @@ -264,12 +282,15 @@ impl GixGitHandler { ) -> GitResult> { let refs = repo .references() - .map_err(|e| GitError::GixError(e.to_string()))?; - let tag_iter = refs.tags().map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; + let tag_iter = refs.tags().map_err(|e| Self::gix_error(None, e))?; let mut matching = Vec::new(); for reference in tag_iter { - let reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let raw_oid = reference.id().detach(); // Peel through annotated tag objects to reach the commit. @@ -291,14 +312,17 @@ impl GixGitHandler { fn collect_tags_with_gix(repo: &gix::Repository) -> GitResult> { let refs = repo .references() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; - let tag_iter = refs.tags().map_err(|e| GitError::GixError(e.to_string()))?; + let tag_iter = refs.tags().map_err(|e| Self::gix_error(None, e))?; let mut tags = Vec::new(); for reference in tag_iter { - let mut reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let mut reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let short_name = Self::bstr_to_string(reference.name().shorten()); // The tag object OID (before peeling) - format as hex then truncate to 7 chars @@ -336,7 +360,7 @@ impl GixGitHandler { for name in names.iter() { let remote = repo .find_remote(name.as_ref()) - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; // Prefer the fetch URL; fall back to push URL let url = remote @@ -449,18 +473,18 @@ impl GixGitHandler { Default::default(), )) .all() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let mut commits = Vec::with_capacity(limit.min(256)); for info in walk.skip(offset).take(limit) { - let info = info.map_err(|e| GitError::GixError(e.to_string()))?; + let info = info.map_err(|e| Self::gix_error(None, e))?; let oid = info.id(); let commit = repo .find_object(oid) - .map_err(|e| GitError::GixError(e.to_string()))? + .map_err(|e| Self::gix_error(None, e))? .try_into_commit() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; // Full hex hash let hash = oid.to_hex().to_string(); @@ -470,7 +494,7 @@ impl GixGitHandler { // Author name and email from the author signature let author_sig = commit .author() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let author = Self::bstr_to_string(author_sig.name); let author_email = Self::bstr_to_string(author_sig.email); // Date from author or committer signature depending on the setting @@ -479,7 +503,7 @@ impl GixGitHandler { CommitDateMode::CommitterDate => { commit .committer() - .map_err(|e| GitError::GixError(e.to_string()))? + .map_err(|e| Self::gix_error(None, e))? .time } }; @@ -499,7 +523,7 @@ impl GixGitHandler { // Verification happens lazily via verify_commits. let decoded = commit .decode() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let sig_value = decoded .extra_headers .iter() @@ -653,12 +677,12 @@ impl GixGitHandler { let mut status_iter = repo .status(gix::progress::Discard) - .map_err(|error| GitError::GixError(error.to_string()))? + .map_err(|error| Self::gix_error(None, error))? .into_iter(Vec::::new()) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; while let Some(next_item) = status_iter.next() { - let item = next_item.map_err(|error| GitError::GixError(error.to_string()))?; + let item = next_item.map_err(|error| Self::gix_error(None, error))?; match item { gix::status::Item::IndexWorktree(worktree_item) => { @@ -808,6 +832,7 @@ impl GitOperationHandler for GixGitHandler { output: None, repo_path: Some(resolved_repo_path), backend_used: "gix".to_string(), + interpreted_error: None, }) } @@ -823,19 +848,20 @@ impl GitOperationHandler for GixGitHandler { let should_interrupt = AtomicBool::new(false); let mut prepare = gix::prepare_clone(repo_url, final_destination_str.as_str()) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; let (mut checkout, _) = prepare .fetch_then_checkout(gix::progress::Discard, &should_interrupt) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; checkout .main_worktree(gix::progress::Discard, &should_interrupt) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; Ok(OperationResult { message: format!("Cloned repository to {}", final_destination.display()), output: Some("Clone completed using gix".to_string()), repo_path: Some(final_destination_str), backend_used: "gix".to_string(), + interpreted_error: None, }) } @@ -913,7 +939,7 @@ impl GitOperationHandler for GixGitHandler { fn get_repo_status(&self, request: &RepoRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|error| GitError::GixError(error.to_string())); + let repo = gix::discover(repo_path).map_err(|error| Self::gix_error(None, error)); match repo.and_then(|repository| Self::collect_repo_status_with_gix(&repository)) { Ok(status) => Ok(status), @@ -932,7 +958,7 @@ impl GitOperationHandler for GixGitHandler { let repo_path = Path::new(request.repo_path.trim()); let limit = request.limit.unwrap_or(100).clamp(1, 5000); let offset = request.offset.unwrap_or(0); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| { Self::collect_commit_history_with_gix(&r, limit, offset, &request.commit_date_mode) }) { @@ -943,7 +969,7 @@ impl GitOperationHandler for GixGitHandler { fn get_commit_markers(&self, request: &RepoRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_commit_markers_with_gix(&r)) { Ok(markers) => Ok(markers), Err(_) => self.cli_fallback.get_commit_markers(request), @@ -957,20 +983,20 @@ impl GitOperationHandler for GixGitHandler { fn get_commit_details(&self, request: &CommitDetailsRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string()))?; + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e))?; let oid = gix::ObjectId::from_hex(request.commit_hash.trim().as_bytes()) - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let commit = repo .find_object(oid) - .map_err(|e| GitError::GixError(e.to_string()))? + .map_err(|e| Self::gix_error(None, e))? .try_into_commit() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let author_sig = commit .author() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let author = Self::bstr_to_string(author_sig.name); let author_email = Self::bstr_to_string(author_sig.email); let author_date = gix::date::parse_header(author_sig.time) @@ -979,7 +1005,7 @@ impl GitOperationHandler for GixGitHandler { let committer_sig = commit .committer() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let committer = Self::bstr_to_string(committer_sig.name); let committer_email = Self::bstr_to_string(committer_sig.email); let committer_date = gix::date::parse_header(committer_sig.time) @@ -1020,7 +1046,7 @@ impl GitOperationHandler for GixGitHandler { fn get_branches(&self, request: &RepoRequest) -> GitResult> { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|repository| Self::collect_branches_with_gix(&repository)) { Ok(branches) => Ok(branches), Err(_) => self.cli_fallback.get_branches(request), @@ -1146,7 +1172,7 @@ impl GitOperationHandler for GixGitHandler { fn get_identity(&self, request: &IdentityRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_identity_with_gix(&r, &request.scope)) { Ok(identity) => Ok(identity), Err(_) => self.cli_fallback.get_identity(request), @@ -1162,7 +1188,7 @@ impl GitOperationHandler for GixGitHandler { fn get_tags(&self, request: &RepoRequest) -> GitResult> { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_tags_with_gix(&r)) { Ok(tags) => Ok(tags), Err(_) => self.cli_fallback.get_tags(request), @@ -1171,7 +1197,7 @@ impl GitOperationHandler for GixGitHandler { fn get_remotes(&self, request: &RepoRequest) -> GitResult> { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_remotes_with_gix(&r)) { Ok(remotes) => Ok(remotes), Err(_) => self.cli_fallback.get_remotes(request), diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index e2b3e3b..37d7cf7 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,6 +1,6 @@ pub mod cli; pub mod error; +pub mod error_interpretation; pub mod gix_handler; pub mod handler; pub mod types; - diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 87e9157..8d3f5ee 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Deserializer, Serialize}; +use super::error_interpretation::InterpretedGitError; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum CommitDateMode { AuthorDate, @@ -390,6 +392,8 @@ pub struct OperationResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, } #[derive(Debug, Clone, Serialize)] @@ -501,6 +505,8 @@ pub struct MergeResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, /// True if the merge completed cleanly (fast-forward or auto-merge). pub success: bool, /// True if there are conflicts that require manual resolution. @@ -523,6 +529,8 @@ pub struct RebaseResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, pub success: bool, pub has_conflicts: bool, pub conflicted_files: Vec, @@ -542,6 +550,8 @@ pub struct CherryPickResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, pub success: bool, pub has_conflicts: bool, pub conflicted_files: Vec, @@ -870,6 +880,8 @@ pub struct PushResult { pub backend_used: String, pub success: bool, pub rejection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 61267cf..695e694 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -93,40 +93,63 @@ fn detect_git_backend() -> GitBackend { } pub(crate) fn git_command() -> std::process::Command { - if let Some(git_exe) = configured_git_executable_path() { + let mut command = if let Some(git_exe) = configured_git_executable_path() { #[cfg(windows)] { let mut command = std::process::Command::new(&git_exe); configure_bundled_git_environment(&mut command, &git_exe); - return command; + command } #[cfg(not(windows))] { - return std::process::Command::new(git_exe); + std::process::Command::new(git_exe) } - } - - match git_backend() { - GitBackend::FlatpakHost => { - let mut cmd = std::process::Command::new("flatpak-spawn"); - cmd.args(["--host", "git"]); - cmd - } - GitBackend::FlatpakBundled => std::process::Command::new("/app/bin/git"), - GitBackend::System => { - #[cfg(windows)] - { - let git_exe = resolve_git_exe(); - let mut command = std::process::Command::new(&git_exe); - configure_bundled_git_environment(&mut command, Path::new(&git_exe)); - command + } else { + match git_backend() { + GitBackend::FlatpakHost => { + let mut cmd = std::process::Command::new("flatpak-spawn"); + cmd.args(["--host", "--env=LC_ALL=C", "--env=LANG=C", "git"]); + cmd } - #[cfg(not(windows))] - { - std::process::Command::new(resolve_git_exe()) + GitBackend::FlatpakBundled => std::process::Command::new("/app/bin/git"), + GitBackend::System => { + #[cfg(windows)] + { + let git_exe = resolve_git_exe(); + let mut command = std::process::Command::new(&git_exe); + configure_bundled_git_environment(&mut command, Path::new(&git_exe)); + command + } + #[cfg(not(windows))] + { + std::process::Command::new(resolve_git_exe()) + } } } + }; + + command.env("LC_ALL", "C"); + command.env("LANG", "C"); + command +} + +#[cfg(test)] +mod git_command_tests { + fn command_env_is(command: &std::process::Command, key: &str, value: &str) -> bool { + command + .get_envs() + .any(|(env_key, env_value)| { + env_key == key && env_value == Some(std::ffi::OsStr::new(value)) + }) + } + + #[test] + fn git_command_uses_stable_machine_locale() { + let command = crate::git_command(); + + assert!(command_env_is(&command, "LC_ALL", "C")); + assert!(command_env_is(&command, "LANG", "C")); } } diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index bd8c03a..1e9a2c9 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -185,6 +185,7 @@ export function ProjectView({ winRadius, }: ProjectViewProps) { const { t } = useTranslation("projectView"); + const { t: tGitAdvice } = useTranslation("gitAdvice"); const collapsedRightPaneBonus = leftPaneCollapsed ? Math.max(0, leftPaneWidth + 6 - 22) : 0; @@ -722,16 +723,25 @@ export function ProjectView({ }, [runPullWithStrategy]); const handlePushFailure = useCallback((result: Awaited>) => { + let interpretedMessage: string | null = null; + if (result.interpretedError) { + const actions = result.interpretedError.suggestedActions + .slice(0, 3) + .map((action) => tGitAdvice(`actions.${action}`, { defaultValue: action })) + .join(", "); + interpretedMessage = actions + ? tGitAdvice("withActions", { summary: result.interpretedError.summary, actions }) + : result.interpretedError.summary; + } if (result.rejection && ["non-fast-forward", "no-upstream", "upstream-missing"].includes(result.rejection.kind)) { setPushRejectionAnalysis(result.rejection); - showToast(result.rejection.message, "error"); - appendResultLog("error", result.rejection.message, result.backendUsed); + appendResultLog("error", interpretedMessage ?? result.rejection.message, result.backendUsed); return; } - showToast(result.message, "error"); + showToast(interpretedMessage ?? result.message, "error"); appendResultLog("error", result.output?.trim() || result.message, result.backendUsed); - }, [showToast]); + }, [showToast, tGitAdvice]); const runPushRequest = useCallback(async ( request: PushRequest, diff --git a/src/components/centre/PushRejectedDialog.css b/src/components/centre/PushRejectedDialog.css index 36214fe..54d6015 100644 --- a/src/components/centre/PushRejectedDialog.css +++ b/src/components/centre/PushRejectedDialog.css @@ -1,5 +1,27 @@ .push-rejected-dialog { + position: relative; width: 460px; + overflow: hidden; +} + +.push-rejected-dialog::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 3px; + background: var(--yellow); +} + +.push-rejected-dialog__title { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--yellow); +} + +.push-rejected-dialog__icon { + flex: 0 0 auto; } .push-rejected-dialog__body { diff --git a/src/components/centre/PushRejectedDialog.tsx b/src/components/centre/PushRejectedDialog.tsx index cfddc95..ec738db 100644 --- a/src/components/centre/PushRejectedDialog.tsx +++ b/src/components/centre/PushRejectedDialog.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import type { PushRejectionAnalysis } from "../../types"; +import { WarningIcon } from "../icons"; import "./PushRejectedDialog.css"; type PushRejectedDialogProps = { @@ -36,7 +37,10 @@ export function PushRejectedDialog({ <>
-
{t("pushRejected.title")}
+
+ + {t("pushRejected.title")} +

{analysis.message}

diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 1f1fad6..d433f0a 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -25,6 +25,7 @@ import { Copy, ArrowsLeftRight, Info, + WarningCircle, GithubLogo, } from "@phosphor-icons/react"; @@ -52,6 +53,7 @@ export const TerminalIcon = ({ size = 16, className }: IconProps) => ; export const GlobeIcon = ({ size = 16, className }: IconProps) => ; export const InfoIcon = ({ size = 16, className }: IconProps) => ; +export const WarningIcon = ({ size = 16, className }: IconProps) => ; export const GithubLogoIcon = ({ size = 16, className }: IconProps) => ; export const FolderIcon = ({ size = 16, className }: IconProps) => ; export const CopyIcon = ({ size = 16, className }: IconProps) => ; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index a4d8290..597b694 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,6 +7,7 @@ import clone from "./locales/en/clone.json"; import common from "./locales/en/common.json"; import diffPanel from "./locales/en/diffPanel.json"; import git from "./locales/en/git.json"; +import gitAdvice from "./locales/en/gitAdvice.json"; import identity from "./locales/en/identity.json"; import projectView from "./locales/en/projectView.json"; import resultLog from "./locales/en/resultLog.json"; @@ -23,6 +24,7 @@ export const namespaces = [ "common", "diffPanel", "git", + "gitAdvice", "identity", "projectView", "resultLog", @@ -49,6 +51,7 @@ i18n common, diffPanel, git, + gitAdvice, identity, projectView, resultLog, diff --git a/src/i18n/locales/en/gitAdvice.json b/src/i18n/locales/en/gitAdvice.json new file mode 100644 index 0000000..7dc776c --- /dev/null +++ b/src/i18n/locales/en/gitAdvice.json @@ -0,0 +1,19 @@ +{ + "actions": { + "abort-sequencer": "abort the in-progress operation", + "check-network": "check the network connection", + "continue-sequencer": "continue the in-progress operation", + "fetch": "fetch", + "fix-auth-https": "check HTTPS credentials", + "fix-auth-ssh": "check SSH access", + "integrate": "integrate remote changes", + "open-settings-git-executable": "choose the Git executable in Settings", + "repair-upstream": "repair the upstream branch", + "resolve-conflicts": "resolve conflicts", + "retry": "retry", + "review": "review Git output", + "set-upstream": "set an upstream branch", + "unlock-index": "clear the stale index lock" + }, + "withActions": "{{summary}} Try: {{actions}}." +} diff --git a/src/types.ts b/src/types.ts index 5ea9acc..f41bba5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,32 @@ export type RowStriping = "Off" | "Subtle" | "Strong"; export type UiTextScale = 0.9 | 1 | 1.1 | 1.2 | 1.3; export type AppUpdateChannel = "SelfManaged" | "MicrosoftStore" | "SystemManaged"; +export type GitErrorCategory = + | "auth" + | "network" + | "non-fast-forward" + | "no-upstream" + | "upstream-missing" + | "conflict-in-progress" + | "index-lock" + | "repo-state" + | "invalid-input" + | "tool-unavailable" + | "permission" + | "other"; + +export type GitBackendSource = "git-cli" | "gix"; + +export type InterpretedGitError = { + category: GitErrorCategory; + summary: string; + suggestedActions: string[]; + confidence: number; + backend: GitBackendSource; + rawMessage: string; + operation?: string | null; +}; + export type Settings = { backendMode: BackendMode; showResultLog: boolean; @@ -154,6 +180,7 @@ export type OperationResult = { output?: string | null; repoPath?: string | null; backendUsed: "gix" | "git-cli" | "gix+cli-fallback"; + interpretedError?: InterpretedGitError | null; }; export type RepoRequest = { @@ -287,7 +314,7 @@ export type PushRejectionAnalysis = { upstreamBranch: string | null; kind: PushFailureKind; message: string; - suggestedNextActions: Array<"fetch" | "review" | "integrate" | "publish" | "repair-upstream" | "retry">; + suggestedNextActions: string[]; }; export type PushResult = { @@ -297,6 +324,7 @@ export type PushResult = { backendUsed: "gix" | "git-cli" | "gix+cli-fallback"; success: boolean; rejection?: PushRejectionAnalysis | null; + interpretedError?: InterpretedGitError | null; }; export type FileStatusItem = { @@ -369,6 +397,7 @@ export type MergeResult = { output?: string | null; repoPath?: string | null; backendUsed: "gix" | "git-cli" | "gix+cli-fallback"; + interpretedError?: InterpretedGitError | null; success: boolean; hasConflicts: boolean; conflictedFiles: string[]; @@ -383,6 +412,7 @@ export type RebaseResult = { output?: string | null; repoPath?: string | null; backendUsed: "gix" | "git-cli" | "gix+cli-fallback"; + interpretedError?: InterpretedGitError | null; success: boolean; hasConflicts: boolean; conflictedFiles: string[]; @@ -397,6 +427,7 @@ export type CherryPickResult = { output?: string | null; repoPath?: string | null; backendUsed: "gix" | "git-cli" | "gix+cli-fallback"; + interpretedError?: InterpretedGitError | null; success: boolean; hasConflicts: boolean; conflictedFiles: string[]; From db24ef50dbf0d828a644b16b2b7c15e12c53ecb6 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 21 May 2026 23:22:08 +0100 Subject: [PATCH 2/6] feat: improve interpreted Git error handling --- src-tauri/src/git/error_interpretation.rs | 162 +++++++++++++++++-- src-tauri/tests/git.rs | 106 ++++++++++-- src/components/ProjectView.tsx | 22 +-- src/components/centre/PushRejectedDialog.css | 10 +- src/components/resultlog/ResultLogWindow.css | 29 ++++ src/components/resultlog/ResultLogWindow.tsx | 7 + src/i18n/locales/en/resultLog.json | 1 + src/utils/gitErrorDisplay.test.ts | 95 +++++++++++ src/utils/gitErrorDisplay.ts | 66 ++++++++ src/utils/resultLog.test.ts | 19 ++- src/utils/resultLog.ts | 4 + 11 files changed, 477 insertions(+), 44 deletions(-) create mode 100644 src/utils/gitErrorDisplay.test.ts create mode 100644 src/utils/gitErrorDisplay.ts diff --git a/src-tauri/src/git/error_interpretation.rs b/src-tauri/src/git/error_interpretation.rs index 22a51b5..096d23d 100644 --- a/src-tauri/src/git/error_interpretation.rs +++ b/src-tauri/src/git/error_interpretation.rs @@ -168,6 +168,8 @@ fn classify_message(message: &str) -> (GitErrorCategory, f32) { "could not find remote ref", "upstream is gone", "remote ref does not exist", + "no such ref", + "no such remote ref", ], ) || (lower.contains("remote branch") && contains_any(&lower, &["not found", "does not exist", "missing"])) @@ -175,6 +177,25 @@ fn classify_message(message: &str) -> (GitErrorCategory, f32) { return (GitErrorCategory::UpstreamMissing, 0.9); } + if contains_any( + &lower, + &[ + "could not resolve host", + "failed to connect", + "connection timed out", + "network is unreachable", + "connection reset", + "operation timed out", + "temporary failure in name resolution", + "name or service not known", + "could not resolve hostname", + "unable to access", + "couldn't connect to server", + ], + ) { + return (GitErrorCategory::Network, 0.85); + } + if contains_any( &lower, &[ @@ -192,22 +213,6 @@ fn classify_message(message: &str) -> (GitErrorCategory, f32) { return (GitErrorCategory::Auth, 0.9); } - if contains_any( - &lower, - &[ - "could not resolve host", - "failed to connect", - "connection timed out", - "network is unreachable", - "connection reset", - "operation timed out", - "unable to access", - "couldn't connect to server", - ], - ) { - return (GitErrorCategory::Network, 0.85); - } - if contains_any( &lower, &[ @@ -330,6 +335,21 @@ mod tests { #[derive(Debug)] struct FakeTransportError; + #[derive(Debug)] + struct FakeAuthError; + + #[derive(Debug)] + struct FakeLockTransactionError; + + #[derive(Debug)] + struct FakeConfigError; + + #[derive(Debug)] + struct FakeReferenceRevisionError; + + #[derive(Debug)] + struct FakeFallbackError(&'static str); + impl std::fmt::Display for FakeTransportError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "handshake failed") @@ -338,6 +358,46 @@ mod tests { impl Error for FakeTransportError {} + impl std::fmt::Display for FakeAuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "credential lookup failed") + } + } + + impl Error for FakeAuthError {} + + impl std::fmt::Display for FakeLockTransactionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "transaction could not write lock") + } + } + + impl Error for FakeLockTransactionError {} + + impl std::fmt::Display for FakeConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "config value is invalid") + } + } + + impl Error for FakeConfigError {} + + impl std::fmt::Display for FakeReferenceRevisionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "revision could not be resolved") + } + } + + impl Error for FakeReferenceRevisionError {} + + impl std::fmt::Display for FakeFallbackError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + impl Error for FakeFallbackError {} + #[test] fn cli_non_fast_forward_wins_before_broader_rejected_text() { let interpreted = interpret_cli_error( @@ -383,6 +443,34 @@ mod tests { assert_eq!(network.category, GitErrorCategory::Network); } + #[test] + fn cli_upstream_missing_matches_common_remote_ref_messages() { + let interpreted = interpret_cli_error( + Some("push"), + "fatal: couldn't find remote ref refs/heads/main\nerror: no such ref was fetched", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::UpstreamMissing); + assert!(interpreted.confidence >= 0.85); + } + + #[test] + fn cli_dns_style_output_maps_to_network() { + let interpreted = interpret_cli_error( + Some("fetch"), + "ssh: Could not resolve hostname example.invalid: Name or service not known\nfatal: Could not read from remote repository.", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::Network); + assert!( + interpreted + .suggested_actions + .contains(&"check-network".to_string()) + ); + } + #[test] fn cli_conflict_state_maps_to_sequencer_actions() { let interpreted = interpret_cli_error( @@ -433,6 +521,48 @@ mod tests { assert_eq!(interpreted.backend, GitBackendSource::Gix); } + #[test] + fn gix_typed_auth_family_maps_to_auth() { + let err = FakeAuthError; + let interpreted = interpret_gix_error(Some("fetch"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::Auth); + assert_eq!(interpreted.backend, GitBackendSource::Gix); + } + + #[test] + fn gix_typed_lock_transaction_family_maps_to_index_lock() { + let err = FakeLockTransactionError; + let interpreted = interpret_gix_error(Some("commit"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::IndexLock); + } + + #[test] + fn gix_typed_config_family_maps_to_repo_state() { + let err = FakeConfigError; + let interpreted = interpret_gix_error(Some("status"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::RepoState); + } + + #[test] + fn gix_typed_reference_revision_family_maps_to_repo_state() { + let err = FakeReferenceRevisionError; + let interpreted = interpret_gix_error(Some("log"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::RepoState); + } + + #[test] + fn gix_fallback_message_uses_other_for_unknowns() { + let err = FakeFallbackError("unexpected internal state"); + let interpreted = interpret_gix_error(Some("status"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::Other); + assert!(interpreted.confidence < 0.5); + } + #[test] fn gix_opaque_message_uses_lower_confidence_heuristics() { let err = std::io::Error::other("could not lock config file"); diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index 7b54da4..b0b8bd1 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -4,12 +4,13 @@ use std::process::Command; use tempfile::TempDir; use gitmun_lib::git::cli::CliGitHandler; +use gitmun_lib::git::error_interpretation::GitErrorCategory; use gitmun_lib::git::gix_handler::GixGitHandler; use gitmun_lib::git::handler::GitOperationHandler; use gitmun_lib::git::types::{ CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, CommitRequest, CreateBranchRequest, - FileRequest, PushRequest, RepoRequest, SetBranchUpstreamRequest, StageFilesRequest, - SubmoduleActionRequest, SubmoduleState, + FileRequest, PushFailureKind, PushRequest, RepoRequest, SetBranchUpstreamRequest, + StageFilesRequest, SubmoduleActionRequest, SubmoduleState, }; fn init_repo() -> TempDir { @@ -947,11 +948,18 @@ fn push_changes_classifies_non_fast_forward() { .expect("push_changes"); assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::NonFastForward); + assert!(interpreted.confidence >= 0.9); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("[rejected]") || output.contains("fetch first")), + "raw Git output is preserved" + ); let rejection = result.rejection.expect("push rejection"); - assert!(matches!( - rejection.kind, - gitmun_lib::git::types::PushFailureKind::NonFastForward - )); + assert!(matches!(rejection.kind, PushFailureKind::NonFastForward)); } #[test] @@ -1092,11 +1100,89 @@ fn push_without_upstream_returns_publish_guidance() { .expect("push_changes"); assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::NoUpstream); + assert_eq!(interpreted.suggested_actions, vec!["set-upstream"]); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("no upstream branch")), + "raw Git output is preserved" + ); let rejection = result.rejection.expect("push rejection"); - assert!(matches!( - rejection.kind, - gitmun_lib::git::types::PushFailureKind::NoUpstream - )); + assert!(matches!(rejection.kind, PushFailureKind::NoUpstream)); +} + +#[test] +fn push_with_mismatched_upstream_returns_repair_guidance() { + let (_remote, local) = init_remote_with_clone(); + git( + local.path(), + &["config", "branch.main.merge", "refs/heads/missing"], + ); + + let result = handler() + .push_changes(&push_request(&local)) + .expect("push_changes"); + + assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::UpstreamMissing); + assert!( + interpreted + .suggested_actions + .contains(&"repair-upstream".to_string()) + ); + let rejection = result.rejection.expect("push rejection"); + assert!(matches!(rejection.kind, PushFailureKind::UpstreamMissing)); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("upstream branch")), + "raw Git output is preserved" + ); +} + +#[test] +fn push_to_unresolvable_remote_returns_network_guidance() { + let (_remote, local) = init_remote_with_clone(); + write_file(local.path(), "network.txt", "network"); + git(local.path(), &["add", "network.txt"]); + git(local.path(), &["commit", "-m", "network"]); + git( + local.path(), + &[ + "config", + "core.sshCommand", + "sh -c 'echo \"ssh: Could not resolve hostname example.invalid: Name or service not known\" >&2; exit 255'", + ], + ); + git( + local.path(), + &[ + "remote", + "set-url", + "origin", + "ssh://git@example.invalid/repo.git", + ], + ); + + let result = handler() + .push_changes(&push_request(&local)) + .expect("push_changes"); + + assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::Network); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("Could not resolve hostname")), + "raw Git output is preserved" + ); } #[test] diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index 1e9a2c9..e806d0f 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -58,6 +58,7 @@ import type { ResultLogEntry } from "../utils/resultLog"; import { appendResultLog } from "../utils/resultLog"; import type { PlatformType } from "../hooks/usePlatform"; import type { ToastType } from "../hooks/useToast"; +import { buildPushFailureDisplay } from "../utils/gitErrorDisplay"; import { getRemoteActionState } from "../utils/remoteActionState"; // Tracks whether the no-diff-tool warning has already been shown this session @@ -723,24 +724,15 @@ export function ProjectView({ }, [runPullWithStrategy]); const handlePushFailure = useCallback((result: Awaited>) => { - let interpretedMessage: string | null = null; - if (result.interpretedError) { - const actions = result.interpretedError.suggestedActions - .slice(0, 3) - .map((action) => tGitAdvice(`actions.${action}`, { defaultValue: action })) - .join(", "); - interpretedMessage = actions - ? tGitAdvice("withActions", { summary: result.interpretedError.summary, actions }) - : result.interpretedError.summary; - } - if (result.rejection && ["non-fast-forward", "no-upstream", "upstream-missing"].includes(result.rejection.kind)) { - setPushRejectionAnalysis(result.rejection); - appendResultLog("error", interpretedMessage ?? result.rejection.message, result.backendUsed); + const display = buildPushFailureDisplay(result, tGitAdvice); + if (display.dialogRejection) { + setPushRejectionAnalysis(display.dialogRejection); + appendResultLog("error", display.logMessage, result.backendUsed, undefined, display.logDetails); return; } - showToast(interpretedMessage ?? result.message, "error"); - appendResultLog("error", result.output?.trim() || result.message, result.backendUsed); + showToast(display.toastMessage ?? result.message, "error"); + appendResultLog("error", display.logMessage, result.backendUsed, undefined, display.logDetails); }, [showToast, tGitAdvice]); const runPushRequest = useCallback(async ( diff --git a/src/components/centre/PushRejectedDialog.css b/src/components/centre/PushRejectedDialog.css index 54d6015..2032169 100644 --- a/src/components/centre/PushRejectedDialog.css +++ b/src/components/centre/PushRejectedDialog.css @@ -1,15 +1,21 @@ .push-rejected-dialog { + --push-rejected-warning: var(--yellow); + position: relative; width: 460px; overflow: hidden; } +:root[data-theme="light"] .push-rejected-dialog { + --push-rejected-warning: #d19500; +} + .push-rejected-dialog::before { content: ""; position: absolute; inset: 0 0 auto; height: 3px; - background: var(--yellow); + background: var(--push-rejected-warning); } .push-rejected-dialog__title { @@ -17,7 +23,7 @@ align-items: center; justify-content: center; gap: 8px; - color: var(--yellow); + color: var(--push-rejected-warning); } .push-rejected-dialog__icon { diff --git a/src/components/resultlog/ResultLogWindow.css b/src/components/resultlog/ResultLogWindow.css index 139b24c..387dfb0 100644 --- a/src/components/resultlog/ResultLogWindow.css +++ b/src/components/resultlog/ResultLogWindow.css @@ -192,6 +192,12 @@ color: var(--text-muted); } +.result-log__console-details { + display: block; + margin-left: 0; + color: var(--text-muted); +} + .result-log__dot { width: 8px; height: 8px; @@ -212,6 +218,29 @@ line-height: 1.35; } +.result-log__details { + margin-top: 6px; + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.result-log__details summary { + cursor: pointer; +} + +.result-log__details pre { + margin: 6px 0 0; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border-subtle); + background: var(--bg-elevated); + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + font-size: var(--font-size-xs); +} + .result-log__time { font-size: var(--font-size-xs); color: var(--text-muted); diff --git a/src/components/resultlog/ResultLogWindow.tsx b/src/components/resultlog/ResultLogWindow.tsx index f44a7ba..bd35c79 100644 --- a/src/components/resultlog/ResultLogWindow.tsx +++ b/src/components/resultlog/ResultLogWindow.tsx @@ -118,6 +118,7 @@ export function ResultLogWindow() { {entry.repoPath && [{repoLabel(entry.repoPath)}]} {entry.message} + {entry.details && {"\n"}{entry.details}}
))}
@@ -126,6 +127,12 @@ export function ResultLogWindow() {
{entry.message}
+ {entry.details && ( +
+ {t("labels.details")} +
{entry.details}
+
+ )}
{new Date(entry.ts).toLocaleString()} {entry.repoPath && {repoLabel(entry.repoPath)}} diff --git a/src/i18n/locales/en/resultLog.json b/src/i18n/locales/en/resultLog.json index 4044fd9..ebb599d 100644 --- a/src/i18n/locales/en/resultLog.json +++ b/src/i18n/locales/en/resultLog.json @@ -10,6 +10,7 @@ }, "labels": { "consoleView": "Console view", + "details": "Details", "empty": "No matching results.", "title": "Result Log" } diff --git a/src/utils/gitErrorDisplay.test.ts b/src/utils/gitErrorDisplay.test.ts new file mode 100644 index 0000000..bab98c0 --- /dev/null +++ b/src/utils/gitErrorDisplay.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import i18n from "../i18n"; +import type { InterpretedGitError, PushResult } from "../types"; +import { buildPushFailureDisplay, formatInterpretedGitError } from "./gitErrorDisplay"; + +const tGitAdvice = i18n.getFixedT("en", "gitAdvice"); + +function interpretedError( + overrides: Partial = {}, +): InterpretedGitError { + return { + category: "non-fast-forward", + summary: "Push was rejected because the remote branch has new commits.", + suggestedActions: ["fetch", "review", "integrate"], + confidence: 0.95, + backend: "git-cli", + rawMessage: "! [rejected] main -> main (fetch first)", + operation: "push", + ...overrides, + }; +} + +function pushResult(overrides: Partial = {}): PushResult { + return { + message: "Push failed", + output: "! [rejected] main -> main (fetch first)", + repoPath: "/repo", + backendUsed: "git-cli", + success: false, + rejection: null, + interpretedError: interpretedError(), + ...overrides, + }; +} + +describe("formatInterpretedGitError", () => { + it("renders high-confidence summaries with localised action labels", () => { + expect(formatInterpretedGitError(interpretedError(), tGitAdvice)) + .toBe("Push was rejected because the remote branch has new commits. Try: fetch, review Git output, integrate remote changes."); + }); + + it("uses soft generic advice for low-confidence Other errors", () => { + expect(formatInterpretedGitError(interpretedError({ + category: "other", + summary: "Git failed before the operation could complete.", + suggestedActions: ["review", "retry"], + confidence: 0.2, + }), tGitAdvice)) + .toBe("Git failed before the operation could complete. Try: review Git output, retry."); + }); +}); + +describe("buildPushFailureDisplay", () => { + it("does not return a toast message for dialog-handled push rejections", () => { + const display = buildPushFailureDisplay(pushResult({ + rejection: { + repoPath: "/repo", + currentBranch: "main", + upstreamBranch: "origin/main", + kind: "non-fast-forward", + message: "Push was rejected because the remote branch has new commits.", + suggestedNextActions: ["fetch", "review", "integrate"], + }, + }), tGitAdvice); + + expect(display.dialogRejection?.kind).toBe("non-fast-forward"); + expect(display.toastMessage).toBeNull(); + expect(display.logMessage).toBe("Push was rejected because the remote branch has new commits."); + expect(display.logDetails).toContain("[rejected]"); + }); + + it("uses interpreted toast text for non-dialog push failures", () => { + const display = buildPushFailureDisplay(pushResult({ + interpretedError: interpretedError({ + category: "network", + summary: "Git could not reach the remote.", + suggestedActions: ["check-network", "retry"], + rawMessage: "ssh: Could not resolve hostname example.invalid", + }), + rejection: { + repoPath: "/repo", + currentBranch: "main", + upstreamBranch: "origin/main", + kind: "network", + message: "Git could not reach the remote.", + suggestedNextActions: ["check-network", "retry"], + }, + }), tGitAdvice); + + expect(display.dialogRejection).toBeNull(); + expect(display.toastMessage).toBe("Git could not reach the remote. Try: check the network connection, retry."); + expect(display.logMessage).toBe("Git could not reach the remote."); + expect(display.logDetails).toBe("ssh: Could not resolve hostname example.invalid"); + }); +}); diff --git a/src/utils/gitErrorDisplay.ts b/src/utils/gitErrorDisplay.ts new file mode 100644 index 0000000..d2eaf8e --- /dev/null +++ b/src/utils/gitErrorDisplay.ts @@ -0,0 +1,66 @@ +import type { TFunction } from "i18next"; +import type { InterpretedGitError, PushRejectionAnalysis, PushResult } from "../types"; + +export type GitAdviceTranslator = TFunction<"gitAdvice">; + +export type PushFailureDisplay = { + dialogRejection: PushRejectionAnalysis | null; + toastMessage: string | null; + logMessage: string; + logDetails: string | null; +}; + +const DIALOG_PUSH_REJECTION_KINDS = ["non-fast-forward", "no-upstream", "upstream-missing"]; + +function localisedActionLabels( + actions: string[], + tGitAdvice: GitAdviceTranslator, +): string { + return actions + .slice(0, 3) + .map((action) => tGitAdvice(`actions.${action}`, { defaultValue: action })) + .join(", "); +} + +export function formatInterpretedGitError( + interpretedError: InterpretedGitError, + tGitAdvice: GitAdviceTranslator, +): string { + const actions = localisedActionLabels(interpretedError.suggestedActions, tGitAdvice); + + return actions + ? tGitAdvice("withActions", { summary: interpretedError.summary, actions }) + : interpretedError.summary; +} + +function rawGitDetails(result: PushResult): string | null { + const rawMessage = result.interpretedError?.rawMessage.trim(); + if (rawMessage) { + return rawMessage; + } + + const output = result.output?.trim(); + return output || null; +} + +export function buildPushFailureDisplay( + result: PushResult, + tGitAdvice: GitAdviceTranslator, +): PushFailureDisplay { + const interpretedMessage = result.interpretedError + ? formatInterpretedGitError(result.interpretedError, tGitAdvice) + : null; + const logMessage = result.interpretedError?.summary ?? (result.output?.trim() || result.message); + const logDetails = result.interpretedError ? rawGitDetails(result) : null; + const dialogRejection = result.rejection + && DIALOG_PUSH_REJECTION_KINDS.includes(result.rejection.kind) + ? result.rejection + : null; + + return { + dialogRejection, + toastMessage: dialogRejection ? null : interpretedMessage ?? result.message, + logMessage, + logDetails, + }; +} diff --git a/src/utils/resultLog.test.ts b/src/utils/resultLog.test.ts index 1f3c792..eaa1f32 100644 --- a/src/utils/resultLog.test.ts +++ b/src/utils/resultLog.test.ts @@ -33,14 +33,23 @@ describe("getResultLogEntries", () => { }); test("returns entries previously stored", () => { - appendResultLog("info", "hello", "gix"); + appendResultLog("info", "hello", "gix", null, "raw output"); const entries = getResultLogEntries(); expect(entries).toHaveLength(1); expect(entries[0].message).toBe("hello"); + expect(entries[0].details).toBe("raw output"); expect(entries[0].level).toBe("info"); expect(entries[0].backend).toBe("gix"); }); + test("preserves old entries without details", () => { + localStorage.setItem( + RESULT_LOG_STORAGE_KEY, + JSON.stringify([{ message: "hi", level: "info" }]), + ); + expect(getResultLogEntries()[0].details).toBeNull(); + }); + test("returns empty array for corrupt JSON", () => { localStorage.setItem(RESULT_LOG_STORAGE_KEY, "not-json{{{"); expect(getResultLogEntries()).toEqual([]); @@ -75,6 +84,14 @@ describe("getResultLogEntries", () => { expect(getResultLogEntries()[0].backend).toBe("unknown"); }); + test("drops empty details", () => { + localStorage.setItem( + RESULT_LOG_STORAGE_KEY, + JSON.stringify([{ message: "hi", details: " " }]), + ); + expect(getResultLogEntries()[0].details).toBeNull(); + }); + test("preserves valid success and error levels", () => { localStorage.setItem( RESULT_LOG_STORAGE_KEY, diff --git a/src/utils/resultLog.ts b/src/utils/resultLog.ts index 6ae7247..f70df77 100644 --- a/src/utils/resultLog.ts +++ b/src/utils/resultLog.ts @@ -5,6 +5,7 @@ export type ResultLogEntry = { ts: string; level: ResultLogLevel; message: string; + details?: string | null; backend: "gix" | "git-cli" | "gix+cli-fallback" | "unknown"; repoPath?: string | null; }; @@ -30,6 +31,7 @@ export function getResultLogEntries(): ResultLogEntry[] { ts: entry.ts ?? new Date().toISOString(), level: entry.level === "success" || entry.level === "error" ? entry.level : "info", message: entry.message as string, + details: typeof entry.details === "string" && entry.details.trim() ? entry.details : null, backend: entry.backend === "gix" || entry.backend === "git-cli" || entry.backend === "gix+cli-fallback" ? entry.backend : "unknown", @@ -45,12 +47,14 @@ export function appendResultLog( message: string, backend: ResultLogEntry["backend"] = "unknown", repoPath?: string | null, + details?: string | null, ) { const entry: ResultLogEntry = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ts: new Date().toISOString(), level, message, + details: details?.trim() || null, backend, repoPath: repoPath ?? currentRepoPath, }; From 4d607e9a0d4af3939833a77b4df3f6170e927eaf Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 21 May 2026 23:36:38 +0100 Subject: [PATCH 3/6] fix(git): ensure that --force-with-lease is ran after a rebase --- src-tauri/src/git/cli.rs | 2 ++ src-tauri/src/git/types.rs | 1 + src/components/ProjectView.test.ts | 16 +++++++++++++++- src/components/ProjectView.tsx | 25 ++++++++++++++++++++++--- src/i18n/locales/en/git.json | 1 + src/types.ts | 1 + 6 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs index 278abcb..01e4457 100644 --- a/src-tauri/src/git/cli.rs +++ b/src-tauri/src/git/cli.rs @@ -3758,6 +3758,7 @@ impl GitOperationHandler for CliGitHandler { success: !has_conflicts, has_conflicts, conflicted_files, + rebase_in_progress, }) } @@ -3801,6 +3802,7 @@ impl GitOperationHandler for CliGitHandler { success: !has_conflicts, has_conflicts, conflicted_files, + rebase_in_progress, }) } diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 8d3f5ee..289cb73 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -534,6 +534,7 @@ pub struct RebaseResult { pub success: bool, pub has_conflicts: bool, pub conflicted_files: Vec, + pub rebase_in_progress: bool, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/components/ProjectView.test.ts b/src/components/ProjectView.test.ts index 7301507..a94e149 100644 --- a/src/components/ProjectView.test.ts +++ b/src/components/ProjectView.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import i18n from "../i18n"; -import { buildStashDropPrompt, getEffectiveCommitAction } from "./ProjectView"; +import { buildStashDropPrompt, getEffectiveCommitAction, shouldForceWithLeaseAfterRebase } from "./ProjectView"; const t = i18n.getFixedT("en", "projectView"); @@ -25,3 +25,17 @@ describe("getEffectiveCommitAction", () => { expect(getEffectiveCommitAction("commitAndPush", true)).toBe("commitAndPush"); }); }); + +describe("shouldForceWithLeaseAfterRebase", () => { + it("forces the next push on the rebased branch", () => { + expect(shouldForceWithLeaseAfterRebase("main", "main")).toBe(true); + }); + + it("does not force pushes on other branches", () => { + expect(shouldForceWithLeaseAfterRebase("main", "feature")).toBe(false); + }); + + it("does not force pushes without a completed rebase", () => { + expect(shouldForceWithLeaseAfterRebase(null, "main")).toBe(false); + }); +}); diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index e806d0f..f936bc3 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -129,6 +129,13 @@ export function getEffectiveCommitAction( return canCommitAndPush ? selectedAction : "commit"; } +export function shouldForceWithLeaseAfterRebase( + rebasedBranchAwaitingPush: string | null, + currentBranch: string | null, +): boolean { + return rebasedBranchAwaitingPush !== null && rebasedBranchAwaitingPush === currentBranch; +} + export type ProjectViewProps = { /** The active repository path. Changing this key causes a full remount. */ repoPath: string | null; @@ -241,6 +248,7 @@ export function ProjectView({ const [commitPrimaryAction, setCommitPrimaryActionState] = useState("commit"); const [commitMessageRecommendedLength, setCommitMessageRecommendedLength] = useState(72); const [pushFollowTags, setPushFollowTags] = useState(false); + const [rebasedBranchAwaitingPush, setRebasedBranchAwaitingPush] = useState(null); const [wrapDiffLines, setWrapDiffLines] = useState(false); const [rowStriping, setRowStriping] = useState("Off"); const [searchQuery, setSearchQuery] = useState(""); @@ -294,6 +302,7 @@ export function ProjectView({ const currentBranchInfo = branches.find(b => b.isCurrent && !b.isRemote); const remoteBranches = branches.filter(b => b.isRemote); const remoteActionState = getRemoteActionState(currentBranch, currentBranchInfo); + const forceWithLeaseAfterRebase = shouldForceWithLeaseAfterRebase(rebasedBranchAwaitingPush, currentBranch); const canCommitAndPush = currentBranchInfo?.upstreamStatus === "tracked"; const effectiveCommitAction = getEffectiveCommitAction(commitPrimaryAction, canCommitAndPush); const remoteActionLabel = remoteActionState.kind === "publish" @@ -305,7 +314,9 @@ export function ProjectView({ ? t("remoteAction.detachedTitle", { ns: "git" }) : remoteActionState.kind === "repair-upstream" ? t("remoteAction.repairUpstreamTitle", { ns: "git" }) - : undefined; + : forceWithLeaseAfterRebase + ? t("remoteAction.forceWithLeaseTitle", { ns: "git" }) + : undefined; useEffect(() => { setLogScope("currentCheckout"); @@ -753,6 +764,9 @@ export function ProjectView({ } showToast(successToast); appendResultLog("success", result.message, result.backendUsed); + if (request.forceWithLease) { + setRebasedBranchAwaitingPush(null); + } await refreshAll(); } catch (e) { showToast(String(e), "error"); @@ -784,9 +798,10 @@ export function ProjectView({ await runPushRequest({ repoPath, + forceWithLease: forceWithLeaseAfterRebase, pushFollowTags, }, t("toast.pushComplete"), t("toast.pushFailed")); - }, [remoteActionState, remoteActionTitle, repoPath, remoteOp, runPushRequest, showToast, pushFollowTags, t]); + }, [forceWithLeaseAfterRebase, remoteActionState, remoteActionTitle, repoPath, remoteOp, runPushRequest, showToast, pushFollowTags, t]); const handleCommitAndPush = useCallback(async (message: string, amend: boolean) => { setIsCommitting(true); @@ -1530,6 +1545,7 @@ export function ProjectView({ } else { showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); + setRebasedBranchAwaitingPush(currentBranch); } await refreshAll(); } catch (e) { @@ -1552,6 +1568,9 @@ export function ProjectView({ } else { showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); + if (!result.rebaseInProgress) { + setRebasedBranchAwaitingPush(currentBranch); + } } await refreshAll(); } catch (e) { @@ -1560,7 +1579,7 @@ export function ProjectView({ } finally { setIsRebaseActionRunning(false); } - }, [repoPath, rebaseInProgress, refreshAll, showToast, t]); + }, [repoPath, rebaseInProgress, currentBranch, refreshAll, showToast, t]); const handleRebaseAbort = useCallback(async () => { if (!repoPath || !rebaseInProgress) return; diff --git a/src/i18n/locales/en/git.json b/src/i18n/locales/en/git.json index 1c5e7a4..2d8f849 100644 --- a/src/i18n/locales/en/git.json +++ b/src/i18n/locales/en/git.json @@ -12,6 +12,7 @@ "publish": "Publish", "repairUpstream": "Repair Upstream", "detachedTitle": "Push is unavailable while HEAD is detached.", + "forceWithLeaseTitle": "Next push will use --force-with-lease because this branch was rebased.", "repairUpstreamTitle": "The configured upstream branch is missing. Repair it before pushing." }, "submoduleState": { diff --git a/src/types.ts b/src/types.ts index f41bba5..9b2dfeb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -416,6 +416,7 @@ export type RebaseResult = { success: boolean; hasConflicts: boolean; conflictedFiles: string[]; + rebaseInProgress: boolean; }; export type CherryPickRequest = RepoRequest & { From c8d4d0456e40cc83fcbe15e344f1f3f0d15d5174 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Fri, 22 May 2026 02:24:20 +0100 Subject: [PATCH 4/6] feat: add repository open-in menu --- src-tauri/capabilities/default.json | 6 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/commands/repo.rs | 274 +++++++++++++++++++++++- src-tauri/src/lib.rs | 72 +++++++ src/api/commands.ts | 10 + src/components/App.tsx | 15 +- src/components/ProjectView.tsx | 4 + src/components/Titlebar.css | 101 ++++++++- src/components/Titlebar.test.tsx | 89 +++++++- src/components/Titlebar.tsx | 136 ++++++++++-- src/components/icons.tsx | 2 + src/i18n/locales/en/app.json | 1 + src/i18n/locales/en/titlebar.json | 5 +- src/types.ts | 9 + 14 files changed, 699 insertions(+), 27 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0dd2f9d..074ff14 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -25,7 +25,11 @@ { "identifier": "opener:allow-open-path", "allow": [ - { "path": "$APPCONFIG" } + { "path": "$APPCONFIG" }, + { "path": "$HOME/**" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD/**" } ] } ] diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 65bac4f..5de1780 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capability for invoking app commands","local":true,"windows":["main","settings","clone-repository","result-log","about","attributions"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-create","core:window:allow-set-focus","core:window:allow-set-title","core:window:allow-show","core:window:allow-close","dialog:allow-open","dialog:allow-ask","os:default","core:menu:allow-popup","core:menu:allow-new","core:menu:allow-append","core:menu:allow-remove","core:window:allow-cursor-position","core:window:allow-outer-position","updater:default","shell:allow-open",{"identifier":"opener:allow-open-path","allow":[{"path":"$APPCONFIG"}]}]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability for invoking app commands","local":true,"windows":["main","settings","clone-repository","result-log","about","attributions"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-create","core:window:allow-set-focus","core:window:allow-set-title","core:window:allow-show","core:window:allow-close","dialog:allow-open","dialog:allow-ask","os:default","core:menu:allow-popup","core:menu:allow-new","core:menu:allow-append","core:menu:allow-remove","core:window:allow-cursor-position","core:window:allow-outer-position","updater:default","shell:allow-open",{"identifier":"opener:allow-open-path","allow":[{"path":"$APPCONFIG"},{"path":"$HOME/**"},{"path":"$DESKTOP/**"},{"path":"$DOCUMENT/**"},{"path":"$DOWNLOAD/**"}]}]}} \ No newline at end of file diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index 23de5e1..53caae1 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -7,10 +7,282 @@ use crate::git::types::{ SubmoduleActionRequest, }; use crate::{AppState, CloneCancelFlag, configure_command}; +use serde::{Deserialize, Serialize}; use std::io::Read; -use std::process::Stdio; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::atomic::Ordering; use tauri::Manager; +use tauri_plugin_opener::OpenerExt; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RepoOpenLocationKind { + FileExplorer, + Terminal, + GitBash, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RepoOpenLocation { + kind: RepoOpenLocationKind, + label: String, + fallback_label: String, + icon_data_url: Option, +} + +#[tauri::command] +pub fn get_repo_open_locations() -> Vec { + let locations = vec![ + RepoOpenLocation { + kind: RepoOpenLocationKind::FileExplorer, + label: default_file_manager_label().to_string(), + fallback_label: default_file_manager_label().to_string(), + icon_data_url: None, + }, + RepoOpenLocation { + kind: RepoOpenLocationKind::Terminal, + label: default_terminal_label().to_string(), + fallback_label: "Terminal".to_string(), + icon_data_url: None, + }, + ]; + + #[cfg(target_os = "windows")] + let locations = { + let mut locations = locations; + if crate::resolve_system_git_bash_exe().is_some() { + locations.push(RepoOpenLocation { + kind: RepoOpenLocationKind::GitBash, + label: "Git Bash".to_string(), + fallback_label: "Git Bash".to_string(), + icon_data_url: None, + }); + } + locations + }; + + locations +} + +#[tauri::command] +pub fn open_repo_location( + repo_path: String, + kind: RepoOpenLocationKind, + app: tauri::AppHandle, +) -> Result { + let path = validate_repo_open_path(&repo_path)?; + + match kind { + RepoOpenLocationKind::FileExplorer => { + app.opener() + .open_path(path.to_string_lossy().to_string(), None::<&str>) + .map_err(|e| format!("Failed to open file manager: {e}"))?; + Ok(repo_open_result( + format!("Opened repository in {}", default_file_manager_label()), + path, + )) + } + RepoOpenLocationKind::Terminal => { + open_terminal_at(&path)?; + Ok(repo_open_result( + "Opened repository in Terminal".to_string(), + path, + )) + } + RepoOpenLocationKind::GitBash => { + open_git_bash_at(&path)?; + Ok(repo_open_result( + "Opened repository in Git Bash".to_string(), + path, + )) + } + } +} + +fn validate_repo_open_path(repo_path: &str) -> Result { + let trimmed = repo_path.trim(); + if trimmed.is_empty() { + return Err("Repository path cannot be empty".to_string()); + } + + let path = PathBuf::from(trimmed); + if !path.is_dir() { + return Err("Repository path must be an existing directory".to_string()); + } + Ok(path) +} + +fn repo_open_result(message: String, path: PathBuf) -> OperationResult { + OperationResult { + message, + output: None, + repo_path: Some(path.to_string_lossy().to_string()), + backend_used: "git-cli".to_string(), + interpreted_error: None, + } +} + +fn default_file_manager_label() -> &'static str { + #[cfg(target_os = "windows")] + { + "File Explorer" + } + #[cfg(target_os = "macos")] + { + "Finder" + } + #[cfg(target_os = "linux")] + { + "File Manager" + } + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + "File Manager" + } +} + +fn default_terminal_label() -> &'static str { + "Terminal" +} + +fn open_terminal_at(path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return open_terminal_at_windows(path); + } + + #[cfg(target_os = "macos")] + { + return open_terminal_at_macos(path); + } + + #[cfg(target_os = "linux")] + { + return open_terminal_at_linux(path); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + let _ = path; + Err("Opening a terminal is not supported on this platform".to_string()) + } +} + +fn open_git_bash_at(path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return open_git_bash_at_windows(path); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = path; + Err("Git Bash is only available on Windows".to_string()) + } +} + +#[cfg(target_os = "windows")] +fn open_git_bash_at_windows(path: &Path) -> Result<(), String> { + let git_bash = crate::resolve_system_git_bash_exe() + .ok_or_else(|| "Git Bash from Git for Windows was not found".to_string())?; + Command::new(git_bash) + .arg(format!("--cd={}", path.display())) + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open Git Bash: {e}")) +} + +#[cfg(target_os = "windows")] +fn open_terminal_at_windows(path: &Path) -> Result<(), String> { + let mut wt = Command::new("wt.exe"); + wt.arg("-d").arg(path); + if wt.spawn().is_ok() { + return Ok(()); + } + + Command::new("cmd.exe") + .arg("/C") + .arg("start") + .arg("") + .arg("/D") + .arg(path) + .arg("cmd.exe") + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open terminal: {e}")) +} + +#[cfg(target_os = "macos")] +fn open_terminal_at_macos(path: &Path) -> Result<(), String> { + Command::new("open") + .arg("-a") + .arg("Terminal") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open Terminal: {e}")) +} + +#[cfg(target_os = "linux")] +fn open_terminal_at_linux(path: &Path) -> Result<(), String> { + let mut errors = Vec::new(); + + if let Some(terminal) = std::env::var_os("TERMINAL").and_then(|value| value.into_string().ok()) + { + if !terminal.trim().is_empty() && spawn_terminal_command(&terminal, path, &mut errors) { + return Ok(()); + } + } + + for command in [ + "x-terminal-emulator", + "gnome-terminal", + "kgx", + "konsole", + "xfce4-terminal", + "mate-terminal", + "lxterminal", + "alacritty", + "kitty", + "wezterm", + "foot", + "xterm", + ] { + if spawn_terminal_command(command, path, &mut errors) { + return Ok(()); + } + } + + Err(if errors.is_empty() { + "No supported terminal emulator was found".to_string() + } else { + format!( + "No supported terminal emulator was found ({})", + errors.join("; ") + ) + }) +} + +#[cfg(target_os = "linux")] +fn spawn_terminal_command(command_spec: &str, path: &Path, errors: &mut Vec) -> bool { + let mut parts = command_spec.split_whitespace(); + let Some(command_name) = parts.next() else { + return false; + }; + + let mut command = Command::new(command_name); + command.args(parts).current_dir(path); + + match command.spawn() { + Ok(_) => true, + Err(error) => { + errors.push(format!("{command_name}: {error}")); + false + } + } +} #[tauri::command] pub async fn get_commit_markers( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 695e694..4417a73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -328,6 +328,76 @@ fn resolve_git_bash_exe() -> Option { .find(|candidate| candidate.exists()) } +#[cfg(windows)] +pub(crate) fn resolve_system_git_bash_exe() -> Option { + fn git_bash_exe_from_git_exe(git_exe: &Path) -> Option { + let parent = git_exe.parent()?; + let root = if parent + .file_name() + .is_some_and(|name| name == "cmd" || name == "bin") + { + parent.parent()? + } else { + parent + }; + let candidate = root.join("git-bash.exe"); + if candidate.exists() { + Some(candidate) + } else { + None + } + } + + fn is_bundled_path(candidate: &Path) -> bool { + let Some(bundled) = bundled_git_exe() else { + return false; + }; + let Some(root) = bundled.parent().and_then(|path| path.parent()) else { + return false; + }; + candidate.starts_with(root) + } + + fn is_git_for_windows_bash(candidate: &Path) -> bool { + candidate.exists() + && !is_bundled_path(candidate) + && candidate + .parent() + .is_some_and(|root| root.join("cmd").join("git.exe").exists()) + } + + let active_git = active_windows_git_exe_path(); + if active_git.is_absolute() && !is_bundled_path(&active_git) { + if let Some(candidate) = git_bash_exe_from_git_exe(&active_git) { + if is_git_for_windows_bash(&candidate) { + return Some(candidate); + } + } + } + + for candidate in [ + r"C:\Program Files\Git\git-bash.exe", + r"C:\Program Files (x86)\Git\git-bash.exe", + ] { + let candidate = PathBuf::from(candidate); + if is_git_for_windows_bash(&candidate) { + return Some(candidate); + } + } + + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + if let Some(candidate) = git_bash_exe_from_git_exe(&dir.join("git.exe")) { + if is_git_for_windows_bash(&candidate) { + return Some(candidate); + } + } + } + } + + None +} + #[cfg(windows)] pub(crate) fn git_bash_command() -> Option { resolve_git_bash_exe().map(std::process::Command::new) @@ -1337,6 +1407,8 @@ pub fn run() { commands::repo::get_commit_details, commands::repo::validate_repo_path, commands::repo::init_repo, + commands::repo::get_repo_open_locations, + commands::repo::open_repo_location, commands::repo::clone_repo, commands::repo::cancel_clone, commands::repo::get_default_clone_dir, diff --git a/src/api/commands.ts b/src/api/commands.ts index 3f15edf..524b6ed 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -36,6 +36,8 @@ import type { PushRequest, PushResult, RemoteInfo, + RepoOpenLocation, + RepoOpenLocationKind, RepoRequest, RepoStatus, RepoOpenBehaviour, @@ -69,6 +71,14 @@ export function getRepoStatus(repoPath: string): Promise { return invoke("get_repo_status", {request: {repoPath}}); } +export function getRepoOpenLocations(): Promise { + return invoke("get_repo_open_locations"); +} + +export function openRepoLocation(repoPath: string, kind: RepoOpenLocationKind): Promise { + return invoke("open_repo_location", {repoPath, kind}); +} + export function getNumstat(repoPath: string, filePath: string, staged: boolean): Promise { return invoke("get_numstat", {request: {repoPath, filePath, staged}}); } diff --git a/src/components/App.tsx b/src/components/App.tsx index 096fb04..71a2b12 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -10,7 +10,7 @@ import {useToast} from "../hooks/useToast"; import {useUpdateFlow} from "../hooks/useUpdateFlow"; import {usePlatform} from "../hooks/usePlatform"; import * as api from "../api/commands"; -import type {AppAvailableUpdate, RepoOpenBehaviour, Settings, ShellStartupAction} from "../types"; +import type {AppAvailableUpdate, RepoOpenBehaviour, RepoOpenLocationKind, Settings, ShellStartupAction} from "../types"; import {appendResultLog, setResultLogRepoPath} from "../utils/resultLog"; import {applyThemeMode} from "../utils/theme"; import {applyUiTextScale} from "../utils/uiTextScale"; @@ -632,6 +632,18 @@ export function App() { await openRepoPath(path); }, [openRepoPath]); + const handleOpenRepoLocation = useCallback(async (kind: RepoOpenLocationKind) => { + if (!repoPath) return; + try { + const result = await api.openRepoLocation(repoPath, kind); + showToast(result.message, "success"); + appendResultLog("info", result.message, result.backendUsed, repoPath); + } catch (e) { + showToast(String(e), "error"); + appendResultLog("error", t("log.openRepoLocationFailed", {message: String(e)}), "unknown", repoPath); + } + }, [repoPath, showToast, t]); + const isNative = true; const winRadius = 0; @@ -655,6 +667,7 @@ export function App() { identityOpen={identityOpen} onIdentityToggle={() => setIdentityOpen(v => !v)} onRepoSelect={handleRepoSelect} + onOpenRepoLocation={handleOpenRepoLocation} onOpenExistingClick={handleOpenExistingClick} onCloneClick={handleCloneClick} onInitRepoClick={handleInitRepoClick} diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index f936bc3..10f18a8 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -51,6 +51,7 @@ import type { PushRequest, PushRejectionAnalysis, RemoteInfo, + RepoOpenLocationKind, RowStriping, StashEntry, } from "../types"; @@ -147,6 +148,7 @@ export type ProjectViewProps = { identityOpen: boolean; onIdentityToggle: () => void; onRepoSelect: (path: string) => void; + onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; onOpenExistingClick: () => void; onCloneClick: () => void; onInitRepoClick: () => void; @@ -175,6 +177,7 @@ export function ProjectView({ identityOpen, onIdentityToggle, onRepoSelect, + onOpenRepoLocation, onOpenExistingClick, onCloneClick, onInitRepoClick, @@ -2032,6 +2035,7 @@ export function ProjectView({ onInitRepoClick={onInitRepoClick} onOpenExistingClick={onOpenExistingClick} onRepoSelect={onRepoSelect} + onOpenRepoLocation={onOpenRepoLocation} onFetch={handleFetch} onPull={handlePull} onPush={handlePush} diff --git a/src/components/Titlebar.css b/src/components/Titlebar.css index 9f66d06..53b3c37 100644 --- a/src/components/Titlebar.css +++ b/src/components/Titlebar.css @@ -24,7 +24,6 @@ .titlebar__name { font-weight: var(--font-weight-semibold); font-size: var(--font-size-lg); - letter-spacing: -0.01em; } .titlebar__sep { @@ -77,6 +76,13 @@ min-width: 0; } +.titlebar__repo-actions { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + .titlebar__action-btn { display: flex; align-items: center; @@ -202,6 +208,14 @@ color: var(--text-primary); } +.titlebar__icon-btn--disabled, +.titlebar__icon-btn--disabled:hover { + opacity: 0.35; + cursor: default; + background: transparent; + color: var(--text-secondary); +} + .titlebar__icon-btn--labeled { display: flex; align-items: center; @@ -248,6 +262,12 @@ color: var(--text-primary); } +.titlebar__open-menu-icon { + width: 14px; + height: 14px; + flex: 0 0 14px; +} + .titlebar__open-menu-item--recent { font-family: var(--font-mono); overflow: hidden; @@ -358,3 +378,82 @@ width: 200px; } } + +@media (max-width: 1260px) { + .titlebar__repo-actions .titlebar__btn-label { + display: none; + } + + .titlebar__repo-actions .titlebar__icon-btn--labeled { + padding: 5px; + } + + .titlebar__search { + min-width: 150px; + width: 150px; + } + + .titlebar__search--active { + width: 176px; + } +} + +@media (max-width: 1160px) { + .titlebar__actions .titlebar__btn-label { + display: none; + } + + .titlebar__action-btn { + position: relative; + padding: 5px; + gap: 0; + } + + .titlebar__badge { + position: absolute; + top: -4px; + right: -4px; + line-height: 14px; + min-width: 14px; + box-sizing: border-box; + text-align: center; + } + + .titlebar__search-hint { + display: none; + } +} + +@media (max-width: 1040px) { + .titlebar { + gap: 6px; + } + + .titlebar__sep { + margin: 0 2px; + } + + .titlebar__repo-dir { + display: none; + } + + .titlebar__branch-pill { + max-width: 150px; + min-width: 0; + } + + .titlebar__branch-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .titlebar__search { + min-width: 118px; + width: 118px; + } + + .titlebar__search--active { + width: 150px; + } +} diff --git a/src/components/Titlebar.test.tsx b/src/components/Titlebar.test.tsx index 8651073..b2b3fac 100644 --- a/src/components/Titlebar.test.tsx +++ b/src/components/Titlebar.test.tsx @@ -1,11 +1,21 @@ // @vitest-environment jsdom import React from "react"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Titlebar } from "./Titlebar"; import type { BranchInfo } from "../types"; import "../i18n"; +vi.mock("../api/commands", () => ({ + getRepoOpenLocations: vi.fn(async () => [ + { kind: "fileExplorer", label: "Explorer App", fallbackLabel: "File Manager", iconDataUrl: null }, + { kind: "terminal", label: "Terminal App", fallbackLabel: "Terminal", iconDataUrl: null }, + { kind: "gitBash", label: "Git Bash", fallbackLabel: "Git Bash", iconDataUrl: null }, + ]), +})); + +import * as api from "../api/commands"; + function makeBranch(overrides: Partial = {}): BranchInfo { return { name: "feature/demo", @@ -19,13 +29,18 @@ function makeBranch(overrides: Partial = {}): BranchInfo { }; } -function renderTitlebar(branches: BranchInfo[], pushLabel = "Push") { +function renderTitlebar( + branches: BranchInfo[], + pushLabel = "Push", + repoPath: string | null = "/repo", + onOpenRepoLocation = vi.fn(), +) { render( { + beforeEach(() => { + vi.mocked(api.getRepoOpenLocations).mockResolvedValue([ + { kind: "fileExplorer", label: "Explorer App", fallbackLabel: "File Manager", iconDataUrl: null }, + { kind: "terminal", label: "Terminal App", fallbackLabel: "Terminal", iconDataUrl: null }, + { kind: "gitBash", label: "Git Bash", fallbackLabel: "Git Bash", iconDataUrl: null }, + ]); + }); + it("shows Publish when the current branch has no upstream", () => { renderTitlebar([makeBranch()], "Publish"); expect(screen.getByText("Publish")).toBeInTheDocument(); @@ -61,4 +85,59 @@ describe("Titlebar", () => { renderTitlebar([makeBranch({ upstream: "origin/feature/demo", upstreamStatus: "tracked" })], "Push"); expect(screen.getByText("Push")).toBeInTheDocument(); }); + + it("disables Open in when no repository is open", () => { + renderTitlebar([], "Push", null); + + const button = screen.getByText("Open in...").closest(".titlebar__icon-btn"); + expect(button).toHaveAttribute("aria-disabled", "true"); + + fireEvent.click(screen.getByText("Open in...")); + expect(screen.queryByText("Explorer App")).not.toBeInTheDocument(); + expect(screen.queryByText("Terminal App")).not.toBeInTheDocument(); + }); + + it("shows file manager and terminal entries when a repository is open", async () => { + renderTitlebar([makeBranch()]); + + fireEvent.click(screen.getByText("Open in...")); + + expect(await screen.findByText("Explorer App")).toBeInTheDocument(); + expect(screen.getByText("Terminal App")).toBeInTheDocument(); + expect(screen.getByText("Git Bash")).toBeInTheDocument(); + }); + + it("calls the open handler with the selected location", async () => { + const onOpenRepoLocation = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", onOpenRepoLocation); + + fireEvent.click(screen.getByText("Open in...")); + fireEvent.click(await screen.findByText("Explorer App")); + expect(onOpenRepoLocation).toHaveBeenCalledWith("fileExplorer"); + + fireEvent.click(screen.getByText("Open in...")); + fireEvent.click(await screen.findByText("Terminal App")); + expect(onOpenRepoLocation).toHaveBeenCalledWith("terminal"); + + fireEvent.click(screen.getByText("Open in...")); + fireEvent.click(await screen.findByText("Git Bash")); + expect(onOpenRepoLocation).toHaveBeenCalledWith("gitBash"); + }); + + it("renders fallback labels when native labels are empty", async () => { + vi.mocked(api.getRepoOpenLocations).mockResolvedValue([ + { kind: "fileExplorer", label: "", fallbackLabel: "File Manager", iconDataUrl: null }, + { kind: "terminal", label: "", fallbackLabel: "Terminal", iconDataUrl: null }, + { kind: "gitBash", label: "", fallbackLabel: "Git Bash", iconDataUrl: null }, + ]); + + renderTitlebar([makeBranch()]); + fireEvent.click(screen.getByText("Open in...")); + + await waitFor(() => { + expect(screen.getByText("File Manager")).toBeInTheDocument(); + expect(screen.getByText("Terminal")).toBeInTheDocument(); + expect(screen.getByText("Git Bash")).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index 6f0e797..ed98f51 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -2,10 +2,11 @@ import React, { useState, useEffect, useRef, RefObject } from "react"; import { useTranslation } from "react-i18next"; import { GitIcon, BranchIcon, FetchIcon, PullIcon, PushIcon, - StashIcon, SearchIcon, SettingsIcon, FolderIcon, CopyIcon, ChevDownIcon, InfoIcon, + StashIcon, SearchIcon, SettingsIcon, FolderIcon, CopyIcon, ChevDownIcon, InfoIcon, TerminalIcon, OpenExternalIcon, } from "./icons"; +import * as api from "../api/commands"; import type { PlatformType } from "../hooks/usePlatform"; -import type { BranchInfo } from "../types"; +import type { BranchInfo, RepoOpenLocation, RepoOpenLocationKind } from "../types"; import "./Titlebar.css"; type TitlebarProps = { @@ -28,6 +29,7 @@ type TitlebarProps = { onInitRepoClick: () => void; onOpenExistingClick: () => void; onRepoSelect: (path: string) => void; + onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; onFetch: () => void; onPull: () => void; onPush: () => void; @@ -56,7 +58,7 @@ export function Titlebar({ platform, native, repoPath, currentBranch, branches, identityInitials, identityAvatarUrl, recentRepos, searchQuery, searchInputRef, onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick, - onRepoSelect, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, + onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, identityOpen, remoteOp, }: TitlebarProps) { const { t } = useTranslation("titlebar"); @@ -121,19 +123,24 @@ export function Titlebar({
- {/* New / clone / open repo */} -
- {t("actions.new")} -
-
- {t("actions.clone")} +
+
+ {t("actions.new")} +
+
+ {t("actions.clone")} +
+ +
-
{/* Search */} @@ -182,6 +189,103 @@ export function Titlebar({ ); } +function fallbackOpenLocations(t: ReturnType>["t"]): RepoOpenLocation[] { + return [ + { + kind: "fileExplorer", + label: t("actions.fileManager"), + fallbackLabel: t("actions.fileManager"), + iconDataUrl: null, + }, + { + kind: "terminal", + label: t("actions.terminal"), + fallbackLabel: t("actions.terminal"), + iconDataUrl: null, + }, + ]; +} + +function OpenInDropdown({ repoPath, onOpenRepoLocation }: { + repoPath: string | null; + onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; +}) { + const { t } = useTranslation("titlebar"); + const [open, setOpen] = useState(false); + const [locations, setLocations] = useState(() => fallbackOpenLocations(t)); + const ref = useRef(null); + const disabled = !repoPath; + + useEffect(() => { + if (!open) return; + let cancelled = false; + api.getRepoOpenLocations() + .then(result => { + if (!cancelled && result.length > 0) { + setLocations(result); + } + }) + .catch(() => { + if (!cancelled) { + setLocations(fallbackOpenLocations(t)); + } + }); + return () => { cancelled = true; }; + }, [open, t]); + + useEffect(() => { + if (disabled) { + setOpen(false); + } + }, [disabled]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + return ( +
+
setOpen(v => !v)} + title={t("actions.openIn")} + aria-disabled={disabled} + > + + {t("actions.openIn")} + +
+ {open && !disabled && ( +
+ {locations.map(location => ( +
{ setOpen(false); onOpenRepoLocation(location.kind); }} + > + {location.iconDataUrl ? ( + + ) : location.kind === "terminal" || location.kind === "gitBash" ? ( + + ) : ( + + )} + {location.label || location.fallbackLabel} +
+ ))} +
+ )} +
+ ); +} + function OpenDropdown({ repoPath, recentRepos, onOpenExistingClick, onRepoSelect }: { repoPath: string | null; recentRepos: string[]; @@ -254,7 +358,7 @@ function ActionBtn({ icon, label, badge, onClick, disabled, loading, title }: {
{loading ? : icon} {label} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index d433f0a..0dff7ab 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -23,6 +23,7 @@ import { Globe, FolderOpen, Copy, + ArrowSquareOut, ArrowsLeftRight, Info, WarningCircle, @@ -57,6 +58,7 @@ export const WarningIcon = ({ size = 16, className }: IconProps) => ; export const FolderIcon = ({ size = 16, className }: IconProps) => ; export const CopyIcon = ({ size = 16, className }: IconProps) => ; +export const OpenExternalIcon = ({ size = 16, className }: IconProps) => ; export const CloseIcon = ({ size = 16, className }: IconProps) => ; export const SwapIcon = ({ size = 16, className }: IconProps) => ; diff --git a/src/i18n/locales/en/app.json b/src/i18n/locales/en/app.json index 6c6fd29..53bbba2 100644 --- a/src/i18n/locales/en/app.json +++ b/src/i18n/locales/en/app.json @@ -15,6 +15,7 @@ "cloneWindowFailed": "Clone window failed to open: {{message}}", "openedRepository": "Opened repository {{path}}", "openedRepositoryFromShell": "Opened repository {{path}} from shell", + "openRepoLocationFailed": "Repository location failed to open: {{message}}", "resultLogWindowFailed": "Result log window failed to open: {{message}}", "settingsUpdated": "Settings updated", "settingsWindowFailed": "Settings window failed to open: {{message}}" diff --git a/src/i18n/locales/en/titlebar.json b/src/i18n/locales/en/titlebar.json index d62b37f..53e0784 100644 --- a/src/i18n/locales/en/titlebar.json +++ b/src/i18n/locales/en/titlebar.json @@ -4,14 +4,17 @@ "clone": "Clone", "cloneRepository": "Clone a repository", "fetch": "Fetch", + "fileManager": "File Manager", "initialiseRepository": "Initialise a repository", "new": "New", "open": "Open", + "openIn": "Open in...", "openRepository": "Open a repository", "openRepositoryMenu": "Open repository...", "pull": "Pull", "push": "Push", - "stash": "Stash" + "stash": "Stash", + "terminal": "Terminal" }, "labels": { "noRepositoryOpen": "No repository open", diff --git a/src/types.ts b/src/types.ts index 9b2dfeb..8b6d56b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -183,6 +183,15 @@ export type OperationResult = { interpretedError?: InterpretedGitError | null; }; +export type RepoOpenLocationKind = "fileExplorer" | "terminal" | "gitBash"; + +export type RepoOpenLocation = { + kind: RepoOpenLocationKind; + label: string; + fallbackLabel: string; + iconDataUrl?: string | null; +}; + export type RepoRequest = { repoPath: string; }; From 51a0a54a8feb0a77326b57d64e4541a240453540 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Fri, 22 May 2026 02:37:09 +0100 Subject: [PATCH 5/6] ci(releases): notify website metadata updates --- .github/workflows/build-bundles.yml | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/build-bundles.yml b/.github/workflows/build-bundles.yml index a69ff09..c1e92cb 100644 --- a/.github/workflows/build-bundles.yml +++ b/.github/workflows/build-bundles.yml @@ -1068,3 +1068,53 @@ jobs: --header "x-ci-signature: sha256=$SIGNATURE" \ --data "$PAYLOAD" \ "${WORKER_URL%/}/webhook" + + notify-website-release-metadata: + name: Notify website release metadata + if: ${{ github.event_name == 'release' }} + needs: + - linux-bundles + - windows-bundles + - update-updater-json + - windows-msix + - macos-bundles + - notify-r2-release-sync + runs-on: ubuntu-22.04 + + steps: + - name: Dispatch website metadata update + env: + DISPATCH_TOKEN: ${{ secrets.GITMUN_WEBSITE_DISPATCH_TOKEN }} + GITHUB_API_URL: ${{ github.api_url }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_URL: ${{ github.event.release.html_url }} + shell: bash + run: | + set -euo pipefail + + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "GITMUN_WEBSITE_DISPATCH_TOKEN is not configured." >&2 + exit 1 + fi + + payload="$( + jq -cn \ + --arg event_type "gitmun-release-assets-ready" \ + --arg tag "$RELEASE_TAG" \ + --arg release_url "$RELEASE_URL" \ + '{ + event_type: $event_type, + client_payload: { + tag: $tag, + release_url: $release_url + } + }' + )" + + curl --fail-with-body \ + --request POST \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${DISPATCH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --data "$payload" \ + "${GITHUB_API_URL%/}/repos/cst8t/gitmun-org-website/dispatches" From c6bc580df1f2fff953d217c79fee0d49561d483d Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Fri, 22 May 2026 02:39:27 +0100 Subject: [PATCH 6/6] fix: enable about website link --- src/components/about/AboutWindow.tsx | 2 +- src/i18n/locales/en/about.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/about/AboutWindow.tsx b/src/components/about/AboutWindow.tsx index c02e54c..d0a0448 100644 --- a/src/components/about/AboutWindow.tsx +++ b/src/components/about/AboutWindow.tsx @@ -88,7 +88,7 @@ export function AboutWindow() {