diff --git a/Sources/Zero/Services/GitPanelService.swift b/Sources/Zero/Services/GitPanelService.swift index 6a96010..6d59c9a 100644 --- a/Sources/Zero/Services/GitPanelService.swift +++ b/Sources/Zero/Services/GitPanelService.swift @@ -117,7 +117,7 @@ class GitPanelService: ObservableObject { try gitService.push(in: containerName) await refresh() } catch { - errorMessage = error.localizedDescription + errorMessage = mapRemoteActionError(error, action: .push) } } @@ -128,7 +128,7 @@ class GitPanelService: ObservableObject { try gitService.pull(in: containerName) await refresh() } catch { - errorMessage = error.localizedDescription + errorMessage = mapRemoteActionError(error, action: .pull) } } @@ -157,4 +157,32 @@ class GitPanelService: ObservableObject { selectedDiffTitle = "Untracked ยท \(path)" selectedDiff = "Untracked files have no git diff until staged." } + + private enum RemoteAction { + case pull + case push + } + + private func mapRemoteActionError(_ error: Error, action: RemoteAction) -> String { + let message = error.localizedDescription + let normalized = message.lowercased() + + if action == .push && containsAny(normalized, patterns: ["non-fast-forward", "failed to push some refs", "[rejected]", "fetch first"]) { + return "Push rejected because remote has new commits. Pull, resolve conflicts if needed, then push again." + } + + if action == .pull && containsAny(normalized, patterns: ["automatic merge failed", "merge conflict", "conflict"]) { + return "Pull hit merge conflicts. Resolve conflicted files, commit, then pull again." + } + + if containsAny(normalized, patterns: ["authentication failed", "permission denied", "could not read from remote repository", "repository not found"]) { + return "Git authentication or permission failed. Verify credentials and repository access, then retry." + } + + return message + } + + private func containsAny(_ text: String, patterns: [String]) -> Bool { + patterns.contains { text.contains($0) } + } } diff --git a/Tests/ZeroTests/GitPanelServiceTests.swift b/Tests/ZeroTests/GitPanelServiceTests.swift new file mode 100644 index 0000000..eaf2a70 --- /dev/null +++ b/Tests/ZeroTests/GitPanelServiceTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import Zero + +private final class GitPanelMockContainerRunner: ContainerRunning { + var nextShellOutput = "" + var shellErrorsByCommandSubstring: [String: Error] = [:] + + func executeCommand(container: String, command: String) throws -> String { + return "" + } + + func executeShell(container: String, script: String) throws -> String { + if let match = shellErrorsByCommandSubstring.first(where: { script.contains($0.key) }) { + throw match.value + } + return nextShellOutput + } + + func executeShellStreaming(container: String, script: String, onOutput: @escaping (String) -> Void) throws -> String { + if let match = shellErrorsByCommandSubstring.first(where: { script.contains($0.key) }) { + throw match.value + } + onOutput(nextShellOutput) + return nextShellOutput + } +} + +@MainActor +final class GitPanelServiceTests: XCTestCase { + func testPushMapsNonFastForwardFailureToGuidance() async { + // Given + let runner = GitPanelMockContainerRunner() + runner.shellErrorsByCommandSubstring["git push"] = NSError( + domain: "git", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "! [rejected] main -> main (non-fast-forward)\nerror: failed to push some refs"] + ) + let service = makeService(runner: runner) + + // When + await service.push() + + // Then + XCTAssertEqual( + service.errorMessage, + "Push rejected because remote has new commits. Pull, resolve conflicts if needed, then push again." + ) + } + + func testPullMapsMergeConflictFailureToGuidance() async { + // Given + let runner = GitPanelMockContainerRunner() + runner.shellErrorsByCommandSubstring["git pull"] = NSError( + domain: "git", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Automatic merge failed; fix conflicts and then commit the result."] + ) + let service = makeService(runner: runner) + + // When + await service.pull() + + // Then + XCTAssertEqual( + service.errorMessage, + "Pull hit merge conflicts. Resolve conflicted files, commit, then pull again." + ) + } + + func testPullMapsAuthenticationFailureToGuidance() async { + // Given + let runner = GitPanelMockContainerRunner() + runner.shellErrorsByCommandSubstring["git pull"] = NSError( + domain: "git", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "fatal: Authentication failed for 'https://github.com/zero-ide/Zero.git/'"] + ) + let service = makeService(runner: runner) + + // When + await service.pull() + + // Then + XCTAssertEqual( + service.errorMessage, + "Git authentication or permission failed. Verify credentials and repository access, then retry." + ) + } + + func testPushKeepsOriginalMessageForUnknownFailures() async { + // Given + let runner = GitPanelMockContainerRunner() + runner.shellErrorsByCommandSubstring["git push"] = NSError( + domain: "git", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "fatal: unexpected socket close"] + ) + let service = makeService(runner: runner) + + // When + await service.push() + + // Then + XCTAssertEqual(service.errorMessage, "fatal: unexpected socket close") + } + + private func makeService(runner: GitPanelMockContainerRunner) -> GitPanelService { + let service = GitPanelService() + service.setup(gitService: GitService(runner: runner), containerName: "zero-dev-container") + return service + } +}