Skip to content

Commit db24ef5

Browse files
committed
feat: improve interpreted Git error handling
1 parent f1ec48d commit db24ef5

11 files changed

Lines changed: 477 additions & 44 deletions

File tree

‎src-tauri/src/git/error_interpretation.rs‎

Lines changed: 146 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,34 @@ fn classify_message(message: &str) -> (GitErrorCategory, f32) {
168168
"could not find remote ref",
169169
"upstream is gone",
170170
"remote ref does not exist",
171+
"no such ref",
172+
"no such remote ref",
171173
],
172174
) || (lower.contains("remote branch")
173175
&& contains_any(&lower, &["not found", "does not exist", "missing"]))
174176
{
175177
return (GitErrorCategory::UpstreamMissing, 0.9);
176178
}
177179

180+
if contains_any(
181+
&lower,
182+
&[
183+
"could not resolve host",
184+
"failed to connect",
185+
"connection timed out",
186+
"network is unreachable",
187+
"connection reset",
188+
"operation timed out",
189+
"temporary failure in name resolution",
190+
"name or service not known",
191+
"could not resolve hostname",
192+
"unable to access",
193+
"couldn't connect to server",
194+
],
195+
) {
196+
return (GitErrorCategory::Network, 0.85);
197+
}
198+
178199
if contains_any(
179200
&lower,
180201
&[
@@ -192,22 +213,6 @@ fn classify_message(message: &str) -> (GitErrorCategory, f32) {
192213
return (GitErrorCategory::Auth, 0.9);
193214
}
194215

195-
if contains_any(
196-
&lower,
197-
&[
198-
"could not resolve host",
199-
"failed to connect",
200-
"connection timed out",
201-
"network is unreachable",
202-
"connection reset",
203-
"operation timed out",
204-
"unable to access",
205-
"couldn't connect to server",
206-
],
207-
) {
208-
return (GitErrorCategory::Network, 0.85);
209-
}
210-
211216
if contains_any(
212217
&lower,
213218
&[
@@ -330,6 +335,21 @@ mod tests {
330335
#[derive(Debug)]
331336
struct FakeTransportError;
332337

338+
#[derive(Debug)]
339+
struct FakeAuthError;
340+
341+
#[derive(Debug)]
342+
struct FakeLockTransactionError;
343+
344+
#[derive(Debug)]
345+
struct FakeConfigError;
346+
347+
#[derive(Debug)]
348+
struct FakeReferenceRevisionError;
349+
350+
#[derive(Debug)]
351+
struct FakeFallbackError(&'static str);
352+
333353
impl std::fmt::Display for FakeTransportError {
334354
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335355
write!(f, "handshake failed")
@@ -338,6 +358,46 @@ mod tests {
338358

339359
impl Error for FakeTransportError {}
340360

361+
impl std::fmt::Display for FakeAuthError {
362+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363+
write!(f, "credential lookup failed")
364+
}
365+
}
366+
367+
impl Error for FakeAuthError {}
368+
369+
impl std::fmt::Display for FakeLockTransactionError {
370+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371+
write!(f, "transaction could not write lock")
372+
}
373+
}
374+
375+
impl Error for FakeLockTransactionError {}
376+
377+
impl std::fmt::Display for FakeConfigError {
378+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379+
write!(f, "config value is invalid")
380+
}
381+
}
382+
383+
impl Error for FakeConfigError {}
384+
385+
impl std::fmt::Display for FakeReferenceRevisionError {
386+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387+
write!(f, "revision could not be resolved")
388+
}
389+
}
390+
391+
impl Error for FakeReferenceRevisionError {}
392+
393+
impl std::fmt::Display for FakeFallbackError {
394+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395+
write!(f, "{}", self.0)
396+
}
397+
}
398+
399+
impl Error for FakeFallbackError {}
400+
341401
#[test]
342402
fn cli_non_fast_forward_wins_before_broader_rejected_text() {
343403
let interpreted = interpret_cli_error(
@@ -383,6 +443,34 @@ mod tests {
383443
assert_eq!(network.category, GitErrorCategory::Network);
384444
}
385445

446+
#[test]
447+
fn cli_upstream_missing_matches_common_remote_ref_messages() {
448+
let interpreted = interpret_cli_error(
449+
Some("push"),
450+
"fatal: couldn't find remote ref refs/heads/main\nerror: no such ref was fetched",
451+
Some(128),
452+
);
453+
454+
assert_eq!(interpreted.category, GitErrorCategory::UpstreamMissing);
455+
assert!(interpreted.confidence >= 0.85);
456+
}
457+
458+
#[test]
459+
fn cli_dns_style_output_maps_to_network() {
460+
let interpreted = interpret_cli_error(
461+
Some("fetch"),
462+
"ssh: Could not resolve hostname example.invalid: Name or service not known\nfatal: Could not read from remote repository.",
463+
Some(128),
464+
);
465+
466+
assert_eq!(interpreted.category, GitErrorCategory::Network);
467+
assert!(
468+
interpreted
469+
.suggested_actions
470+
.contains(&"check-network".to_string())
471+
);
472+
}
473+
386474
#[test]
387475
fn cli_conflict_state_maps_to_sequencer_actions() {
388476
let interpreted = interpret_cli_error(
@@ -433,6 +521,48 @@ mod tests {
433521
assert_eq!(interpreted.backend, GitBackendSource::Gix);
434522
}
435523

524+
#[test]
525+
fn gix_typed_auth_family_maps_to_auth() {
526+
let err = FakeAuthError;
527+
let interpreted = interpret_gix_error(Some("fetch"), &err);
528+
529+
assert_eq!(interpreted.category, GitErrorCategory::Auth);
530+
assert_eq!(interpreted.backend, GitBackendSource::Gix);
531+
}
532+
533+
#[test]
534+
fn gix_typed_lock_transaction_family_maps_to_index_lock() {
535+
let err = FakeLockTransactionError;
536+
let interpreted = interpret_gix_error(Some("commit"), &err);
537+
538+
assert_eq!(interpreted.category, GitErrorCategory::IndexLock);
539+
}
540+
541+
#[test]
542+
fn gix_typed_config_family_maps_to_repo_state() {
543+
let err = FakeConfigError;
544+
let interpreted = interpret_gix_error(Some("status"), &err);
545+
546+
assert_eq!(interpreted.category, GitErrorCategory::RepoState);
547+
}
548+
549+
#[test]
550+
fn gix_typed_reference_revision_family_maps_to_repo_state() {
551+
let err = FakeReferenceRevisionError;
552+
let interpreted = interpret_gix_error(Some("log"), &err);
553+
554+
assert_eq!(interpreted.category, GitErrorCategory::RepoState);
555+
}
556+
557+
#[test]
558+
fn gix_fallback_message_uses_other_for_unknowns() {
559+
let err = FakeFallbackError("unexpected internal state");
560+
let interpreted = interpret_gix_error(Some("status"), &err);
561+
562+
assert_eq!(interpreted.category, GitErrorCategory::Other);
563+
assert!(interpreted.confidence < 0.5);
564+
}
565+
436566
#[test]
437567
fn gix_opaque_message_uses_lower_confidence_heuristics() {
438568
let err = std::io::Error::other("could not lock config file");

‎src-tauri/tests/git.rs‎

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ use std::process::Command;
44
use tempfile::TempDir;
55

66
use gitmun_lib::git::cli::CliGitHandler;
7+
use gitmun_lib::git::error_interpretation::GitErrorCategory;
78
use gitmun_lib::git::gix_handler::GixGitHandler;
89
use gitmun_lib::git::handler::GitOperationHandler;
910
use gitmun_lib::git::types::{
1011
CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, CommitRequest, CreateBranchRequest,
11-
FileRequest, PushRequest, RepoRequest, SetBranchUpstreamRequest, StageFilesRequest,
12-
SubmoduleActionRequest, SubmoduleState,
12+
FileRequest, PushFailureKind, PushRequest, RepoRequest, SetBranchUpstreamRequest,
13+
StageFilesRequest, SubmoduleActionRequest, SubmoduleState,
1314
};
1415

1516
fn init_repo() -> TempDir {
@@ -947,11 +948,18 @@ fn push_changes_classifies_non_fast_forward() {
947948
.expect("push_changes");
948949

949950
assert!(!result.success);
951+
let interpreted = result.interpreted_error.expect("interpreted error");
952+
assert_eq!(interpreted.category, GitErrorCategory::NonFastForward);
953+
assert!(interpreted.confidence >= 0.9);
954+
assert!(
955+
result
956+
.output
957+
.as_deref()
958+
.is_some_and(|output| output.contains("[rejected]") || output.contains("fetch first")),
959+
"raw Git output is preserved"
960+
);
950961
let rejection = result.rejection.expect("push rejection");
951-
assert!(matches!(
952-
rejection.kind,
953-
gitmun_lib::git::types::PushFailureKind::NonFastForward
954-
));
962+
assert!(matches!(rejection.kind, PushFailureKind::NonFastForward));
955963
}
956964

957965
#[test]
@@ -1092,11 +1100,89 @@ fn push_without_upstream_returns_publish_guidance() {
10921100
.expect("push_changes");
10931101

10941102
assert!(!result.success);
1103+
let interpreted = result.interpreted_error.expect("interpreted error");
1104+
assert_eq!(interpreted.category, GitErrorCategory::NoUpstream);
1105+
assert_eq!(interpreted.suggested_actions, vec!["set-upstream"]);
1106+
assert!(
1107+
result
1108+
.output
1109+
.as_deref()
1110+
.is_some_and(|output| output.contains("no upstream branch")),
1111+
"raw Git output is preserved"
1112+
);
10951113
let rejection = result.rejection.expect("push rejection");
1096-
assert!(matches!(
1097-
rejection.kind,
1098-
gitmun_lib::git::types::PushFailureKind::NoUpstream
1099-
));
1114+
assert!(matches!(rejection.kind, PushFailureKind::NoUpstream));
1115+
}
1116+
1117+
#[test]
1118+
fn push_with_mismatched_upstream_returns_repair_guidance() {
1119+
let (_remote, local) = init_remote_with_clone();
1120+
git(
1121+
local.path(),
1122+
&["config", "branch.main.merge", "refs/heads/missing"],
1123+
);
1124+
1125+
let result = handler()
1126+
.push_changes(&push_request(&local))
1127+
.expect("push_changes");
1128+
1129+
assert!(!result.success);
1130+
let interpreted = result.interpreted_error.expect("interpreted error");
1131+
assert_eq!(interpreted.category, GitErrorCategory::UpstreamMissing);
1132+
assert!(
1133+
interpreted
1134+
.suggested_actions
1135+
.contains(&"repair-upstream".to_string())
1136+
);
1137+
let rejection = result.rejection.expect("push rejection");
1138+
assert!(matches!(rejection.kind, PushFailureKind::UpstreamMissing));
1139+
assert!(
1140+
result
1141+
.output
1142+
.as_deref()
1143+
.is_some_and(|output| output.contains("upstream branch")),
1144+
"raw Git output is preserved"
1145+
);
1146+
}
1147+
1148+
#[test]
1149+
fn push_to_unresolvable_remote_returns_network_guidance() {
1150+
let (_remote, local) = init_remote_with_clone();
1151+
write_file(local.path(), "network.txt", "network");
1152+
git(local.path(), &["add", "network.txt"]);
1153+
git(local.path(), &["commit", "-m", "network"]);
1154+
git(
1155+
local.path(),
1156+
&[
1157+
"config",
1158+
"core.sshCommand",
1159+
"sh -c 'echo \"ssh: Could not resolve hostname example.invalid: Name or service not known\" >&2; exit 255'",
1160+
],
1161+
);
1162+
git(
1163+
local.path(),
1164+
&[
1165+
"remote",
1166+
"set-url",
1167+
"origin",
1168+
"ssh://git@example.invalid/repo.git",
1169+
],
1170+
);
1171+
1172+
let result = handler()
1173+
.push_changes(&push_request(&local))
1174+
.expect("push_changes");
1175+
1176+
assert!(!result.success);
1177+
let interpreted = result.interpreted_error.expect("interpreted error");
1178+
assert_eq!(interpreted.category, GitErrorCategory::Network);
1179+
assert!(
1180+
result
1181+
.output
1182+
.as_deref()
1183+
.is_some_and(|output| output.contains("Could not resolve hostname")),
1184+
"raw Git output is preserved"
1185+
);
11001186
}
11011187

11021188
#[test]

‎src/components/ProjectView.tsx‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import type { ResultLogEntry } from "../utils/resultLog";
5858
import { appendResultLog } from "../utils/resultLog";
5959
import type { PlatformType } from "../hooks/usePlatform";
6060
import type { ToastType } from "../hooks/useToast";
61+
import { buildPushFailureDisplay } from "../utils/gitErrorDisplay";
6162
import { getRemoteActionState } from "../utils/remoteActionState";
6263

6364
// Tracks whether the no-diff-tool warning has already been shown this session
@@ -723,24 +724,15 @@ export function ProjectView({
723724
}, [runPullWithStrategy]);
724725

725726
const handlePushFailure = useCallback((result: Awaited<ReturnType<typeof api.pushChanges>>) => {
726-
let interpretedMessage: string | null = null;
727-
if (result.interpretedError) {
728-
const actions = result.interpretedError.suggestedActions
729-
.slice(0, 3)
730-
.map((action) => tGitAdvice(`actions.${action}`, { defaultValue: action }))
731-
.join(", ");
732-
interpretedMessage = actions
733-
? tGitAdvice("withActions", { summary: result.interpretedError.summary, actions })
734-
: result.interpretedError.summary;
735-
}
736-
if (result.rejection && ["non-fast-forward", "no-upstream", "upstream-missing"].includes(result.rejection.kind)) {
737-
setPushRejectionAnalysis(result.rejection);
738-
appendResultLog("error", interpretedMessage ?? result.rejection.message, result.backendUsed);
727+
const display = buildPushFailureDisplay(result, tGitAdvice);
728+
if (display.dialogRejection) {
729+
setPushRejectionAnalysis(display.dialogRejection);
730+
appendResultLog("error", display.logMessage, result.backendUsed, undefined, display.logDetails);
739731
return;
740732
}
741733

742-
showToast(interpretedMessage ?? result.message, "error");
743-
appendResultLog("error", result.output?.trim() || result.message, result.backendUsed);
734+
showToast(display.toastMessage ?? result.message, "error");
735+
appendResultLog("error", display.logMessage, result.backendUsed, undefined, display.logDetails);
744736
}, [showToast, tGitAdvice]);
745737

746738
const runPushRequest = useCallback(async (

0 commit comments

Comments
 (0)