Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions Sources/Zero/Services/GitPanelService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ class GitPanelService: ObservableObject {
try gitService.push(in: containerName)
await refresh()
} catch {
errorMessage = error.localizedDescription
errorMessage = mapRemoteActionError(error, action: .push)
}
}

Expand All @@ -128,7 +128,7 @@ class GitPanelService: ObservableObject {
try gitService.pull(in: containerName)
await refresh()
} catch {
errorMessage = error.localizedDescription
errorMessage = mapRemoteActionError(error, action: .pull)
}
}

Expand Down Expand Up @@ -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) }
}
}
112 changes: 112 additions & 0 deletions Tests/ZeroTests/GitPanelServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}