diff --git a/Sources/Zero/Services/GitPanelService.swift b/Sources/Zero/Services/GitPanelService.swift index 6d59c9a..dee978f 100644 --- a/Sources/Zero/Services/GitPanelService.swift +++ b/Sources/Zero/Services/GitPanelService.swift @@ -128,6 +128,12 @@ class GitPanelService: ObservableObject { try gitService.pull(in: containerName) await refresh() } catch { + if isPullConflictError(error) { + let conflictedFiles = (try? gitService.conflictedFiles(in: containerName)) ?? [] + errorMessage = pullConflictGuidance(conflictedFiles: conflictedFiles) + return + } + errorMessage = mapRemoteActionError(error, action: .pull) } } @@ -182,6 +188,27 @@ class GitPanelService: ObservableObject { return message } + private func isPullConflictError(_ error: Error) -> Bool { + let normalized = error.localizedDescription.lowercased() + return containsAny(normalized, patterns: ["automatic merge failed", "merge conflict", "conflict"]) + } + + private func pullConflictGuidance(conflictedFiles: [String]) -> String { + let files = conflictedFiles.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !files.isEmpty else { + return "Pull hit merge conflicts. Resolve conflicted files, commit, then pull again." + } + + let maxPreview = 3 + let previewFiles = Array(files.prefix(maxPreview)).joined(separator: ", ") + let remaining = files.count - maxPreview + let suffix = remaining > 0 ? " (+\(remaining) more)" : "" + + return "Pull hit merge conflicts in \(previewFiles)\(suffix). Resolve those files, commit, then pull again." + } + 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 index eaf2a70..ba6a555 100644 --- a/Tests/ZeroTests/GitPanelServiceTests.swift +++ b/Tests/ZeroTests/GitPanelServiceTests.swift @@ -50,6 +50,31 @@ final class GitPanelServiceTests: XCTestCase { func testPullMapsMergeConflictFailureToGuidance() async { // Given let runner = GitPanelMockContainerRunner() + runner.nextShellOutput = """ + Sources/Zero/Views/EditorView.swift + Sources/Zero/Services/GitPanelService.swift + """ + 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 in Sources/Zero/Views/EditorView.swift, Sources/Zero/Services/GitPanelService.swift. Resolve those files, commit, then pull again." + ) + } + + func testPullConflictFallsBackToGenericGuidanceWhenNoFilesDetected() async { + // Given + let runner = GitPanelMockContainerRunner() + runner.nextShellOutput = "" runner.shellErrorsByCommandSubstring["git pull"] = NSError( domain: "git", code: 1,