diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 7c756685..5bfc132f 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -1,64 +1,4 @@ import Foundation -import Sentry - -enum GitOperation: String { - case repoRoot = "repo_root" - case worktreeList = "worktree_list" - case worktreeCreate = "worktree_create" - case worktreeRemove = "worktree_remove" - case worktreePrune = "worktree_prune" - case repoIsBare = "repo_is_bare" - case branchNames = "branch_names" - case branchNameValidation = "branch_name_validation" - case branchRefs = "branch_refs" - case defaultRemoteBranchRef = "default_remote_branch_ref" - case localHeadRef = "local_head_ref" - case ignoredFileCount = "ignored_file_count" - case untrackedFileCount = "untracked_file_count" - case branchRename = "branch_rename" - case branchDelete = "branch_delete" - case lineChanges = "line_changes" - case diffNameStatus = "diff_name_status" - case untrackedFilePaths = "untracked_file_paths" - case showFile = "show_file" - case remoteInfo = "remote_info" - case remoteList = "remote_list" - case fetchRemote = "fetch_remote" -} - -enum GitClientError: LocalizedError { - case commandFailed(command: String, message: String) - - var errorDescription: String? { - switch self { - case .commandFailed(let command, let message): - if message.isEmpty { - return "Git command failed: \(command)" - } - return "Git command failed: \(command)\n\(message)" - } - } -} - -enum GitWorktreeCreateEvent: Equatable, Sendable { - case outputLine(ShellStreamLine) - case finished(Worktree) -} - -enum LocalBranchDeletionOutcome: Equatable, Sendable { - case deleted - case notFound - case protected - case notRequested -} - -nonisolated enum GitRemoteMatcher { - static func matchingRemote(for ref: String, from remotes: [String]) -> String? { - remotes - .sorted { $0.count > $1.count } - .first { ref.hasPrefix("\($0)/") } - } -} struct GitClient { private struct WorktreeSortEntry { @@ -1018,65 +958,3 @@ struct GitClient { } } - -private nonisolated let gitLogger = SupaLogger("Git") - -nonisolated private func shouldFallbackToLoginShell(_ error: Error) -> Bool { - guard let shellError = error as? ShellClientError else { - return false - } - if shellError.exitCode == 127 { - return true - } - let output = "\(shellError.stderr)\n\(shellError.stdout)".lowercased() - return output.contains("command not found") -} - -nonisolated private func wrapShellError( - _ error: Error, - operation: GitOperation, - command: String -) -> GitClientError { - let gitError: GitClientError - var exitCode: Int32 = -1 - if let shellError = error as? ShellClientError { - exitCode = shellError.exitCode - var messageParts: [String] = [] - if !shellError.stdout.isEmpty { - messageParts.append("stdout:\n\(shellError.stdout)") - } - if !shellError.stderr.isEmpty { - messageParts.append("stderr:\n\(shellError.stderr)") - } - let message = messageParts.joined(separator: "\n") - gitError = .commandFailed(command: command, message: message) - } else { - gitError = .commandFailed(command: command, message: error.localizedDescription) - } - gitLogger.warning("git command failed operation=\(operation.rawValue) exit_code=\(exitCode)") - #if !DEBUG - SentrySDK.logger.error( - "git command failed", - attributes: [ - "operation": operation.rawValue, - "exit_code": Int(exitCode), - ] - ) - #endif - return gitError -} - -struct GitWtWorktreeEntry: Decodable, Equatable { - let branch: String - let path: String - let head: String - let isBare: Bool - - enum CodingKeys: String, CodingKey { - case branch - case path - case head - case isBare = "is_bare" - } - -} diff --git a/supacode/Clients/Git/GitClientShellHelpers.swift b/supacode/Clients/Git/GitClientShellHelpers.swift new file mode 100644 index 00000000..8b7ae55e --- /dev/null +++ b/supacode/Clients/Git/GitClientShellHelpers.swift @@ -0,0 +1,49 @@ +import Foundation +import Sentry + +nonisolated let gitLogger = SupaLogger("Git") + +nonisolated func shouldFallbackToLoginShell(_ error: Error) -> Bool { + guard let shellError = error as? ShellClientError else { + return false + } + if shellError.exitCode == 127 { + return true + } + let output = "\(shellError.stderr)\n\(shellError.stdout)".lowercased() + return output.contains("command not found") +} + +nonisolated func wrapShellError( + _ error: Error, + operation: GitOperation, + command: String +) -> GitClientError { + let gitError: GitClientError + var exitCode: Int32 = -1 + if let shellError = error as? ShellClientError { + exitCode = shellError.exitCode + var messageParts: [String] = [] + if !shellError.stdout.isEmpty { + messageParts.append("stdout:\n\(shellError.stdout)") + } + if !shellError.stderr.isEmpty { + messageParts.append("stderr:\n\(shellError.stderr)") + } + let message = messageParts.joined(separator: "\n") + gitError = .commandFailed(command: command, message: message) + } else { + gitError = .commandFailed(command: command, message: error.localizedDescription) + } + gitLogger.warning("git command failed operation=\(operation.rawValue) exit_code=\(exitCode)") + #if !DEBUG + SentrySDK.logger.error( + "git command failed", + attributes: [ + "operation": operation.rawValue, + "exit_code": Int(exitCode), + ] + ) + #endif + return gitError +} diff --git a/supacode/Clients/Git/GitClientTypes.swift b/supacode/Clients/Git/GitClientTypes.swift new file mode 100644 index 00000000..d8b629c6 --- /dev/null +++ b/supacode/Clients/Git/GitClientTypes.swift @@ -0,0 +1,74 @@ +import Foundation + +enum GitOperation: String { + case repoRoot = "repo_root" + case worktreeList = "worktree_list" + case worktreeCreate = "worktree_create" + case worktreeRemove = "worktree_remove" + case worktreePrune = "worktree_prune" + case repoIsBare = "repo_is_bare" + case branchNames = "branch_names" + case branchNameValidation = "branch_name_validation" + case branchRefs = "branch_refs" + case defaultRemoteBranchRef = "default_remote_branch_ref" + case localHeadRef = "local_head_ref" + case ignoredFileCount = "ignored_file_count" + case untrackedFileCount = "untracked_file_count" + case branchRename = "branch_rename" + case branchDelete = "branch_delete" + case lineChanges = "line_changes" + case diffNameStatus = "diff_name_status" + case untrackedFilePaths = "untracked_file_paths" + case showFile = "show_file" + case remoteInfo = "remote_info" + case remoteList = "remote_list" + case fetchRemote = "fetch_remote" +} + +enum GitClientError: LocalizedError { + case commandFailed(command: String, message: String) + + var errorDescription: String? { + switch self { + case .commandFailed(let command, let message): + if message.isEmpty { + return "Git command failed: \(command)" + } + return "Git command failed: \(command)\n\(message)" + } + } +} + +enum GitWorktreeCreateEvent: Equatable, Sendable { + case outputLine(ShellStreamLine) + case finished(Worktree) +} + +enum LocalBranchDeletionOutcome: Equatable, Sendable { + case deleted + case notFound + case protected + case notRequested +} + +nonisolated enum GitRemoteMatcher { + static func matchingRemote(for ref: String, from remotes: [String]) -> String? { + remotes + .sorted { $0.count > $1.count } + .first { ref.hasPrefix("\($0)/") } + } +} + +struct GitWtWorktreeEntry: Decodable, Equatable { + let branch: String + let path: String + let head: String + let isBare: Bool + + enum CodingKeys: String, CodingKey { + case branch + case path + case head + case isBare = "is_bare" + } +} diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 1f765d8f..61d784d1 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -2,72 +2,6 @@ import ComposableArchitecture import Darwin import Foundation -struct GithubAuthStatus: Equatable, Sendable { - let username: String - let host: String -} - -private struct GithubAuthStatusResponse: Sendable { - let hosts: [String: [GithubAuthAccount]] - - struct GithubAuthAccount: Sendable { - let active: Bool - let login: String - } -} - -extension GithubAuthStatusResponse: Decodable { - private enum CodingKeys: String, CodingKey { - case hosts - } - - nonisolated init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.hosts = try container.decode([String: [GithubAuthAccount]].self, forKey: .hosts) - } -} - -extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { - private enum CodingKeys: String, CodingKey { - case active - case login - } - - nonisolated init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.active = try container.decode(Bool.self, forKey: .active) - self.login = try container.decode(String.self, forKey: .login) - } -} - -nonisolated struct RepoKey: Hashable, Sendable { - let owner: String - let repo: String -} - -nonisolated struct CrossRepoPullRequestRequest: Sendable, Hashable { - let owner: String - let repo: String - let branches: [String] - - var key: RepoKey { - RepoKey(owner: owner, repo: repo) - } -} - -nonisolated struct CrossRepoPullRequestResult: Sendable { - let successByRepo: [RepoKey: [String: GithubPullRequest]] - let failedRepos: [RepoKey: GithubCLIError] - - init( - successByRepo: [RepoKey: [String: GithubPullRequest]] = [:], - failedRepos: [RepoKey: GithubCLIError] = [:] - ) { - self.successByRepo = successByRepo - self.failedRepos = failedRepos - } -} - struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? @@ -131,105 +65,6 @@ extension DependencyValues { } } -private struct GithubPullRequestsRequest: Sendable { - let host: String - let owner: String - let repo: String -} - -nonisolated private struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable { - let owner: Owner - let name: String - let url: String - - nonisolated var remoteInfo: GithubRemoteInfo? { - guard let host = URL(string: url)?.host else { - return nil - } - return GithubRemoteInfo(host: host, owner: owner.login, repo: name) - } - - nonisolated struct Owner: Decodable, Sendable { - let login: String - } -} - -private actor GithubCLIExecutableResolver { - private var cachedExecutableURL: URL? - private var inFlightResolution: Task? - - func executableURL(shell: ShellClient) async throws -> URL { - if let cachedExecutableURL { - return cachedExecutableURL - } - if let inFlightResolution { - return try await inFlightResolution.value - } - let resolutionTask = Task { - try await resolveExecutableURL(shell: shell) - } - inFlightResolution = resolutionTask - do { - let executableURL = try await resolutionTask.value - cachedExecutableURL = executableURL - inFlightResolution = nil - return executableURL - } catch { - inFlightResolution = nil - throw error - } - } - - func invalidate() { - cachedExecutableURL = nil - inFlightResolution?.cancel() - inFlightResolution = nil - } - - private func resolveExecutableURL(shell: ShellClient) async throws -> URL { - if let executableURL = await locateExecutableURL( - shell: shell, - useLoginShell: false - ) { - return executableURL - } - if let executableURL = await locateExecutableURL( - shell: shell, - useLoginShell: true - ) { - return executableURL - } - throw GithubCLIError.unavailable - } - - private func locateExecutableURL( - shell: ShellClient, - useLoginShell: Bool - ) async -> URL? { - let whichURL = URL(fileURLWithPath: "/usr/bin/which") - do { - let output: String - if useLoginShell { - output = try await shell.runLogin( - whichURL, - ["gh"], - nil, - log: false - ).stdout - } else { - output = try await shell.run(whichURL, ["gh"], nil).stdout - } - let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - return URL(fileURLWithPath: trimmed) - } catch { - return nil - } - } -} - nonisolated private func defaultBranchFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver diff --git a/supacode/Clients/Github/GithubCLIExecutableResolver.swift b/supacode/Clients/Github/GithubCLIExecutableResolver.swift new file mode 100644 index 00000000..4e95d990 --- /dev/null +++ b/supacode/Clients/Github/GithubCLIExecutableResolver.swift @@ -0,0 +1,77 @@ +import Foundation + +actor GithubCLIExecutableResolver { + private var cachedExecutableURL: URL? + private var inFlightResolution: Task? + + func executableURL(shell: ShellClient) async throws -> URL { + if let cachedExecutableURL { + return cachedExecutableURL + } + if let inFlightResolution { + return try await inFlightResolution.value + } + let resolutionTask = Task { + try await resolveExecutableURL(shell: shell) + } + inFlightResolution = resolutionTask + do { + let executableURL = try await resolutionTask.value + cachedExecutableURL = executableURL + inFlightResolution = nil + return executableURL + } catch { + inFlightResolution = nil + throw error + } + } + + func invalidate() { + cachedExecutableURL = nil + inFlightResolution?.cancel() + inFlightResolution = nil + } + + private func resolveExecutableURL(shell: ShellClient) async throws -> URL { + if let executableURL = await locateExecutableURL( + shell: shell, + useLoginShell: false + ) { + return executableURL + } + if let executableURL = await locateExecutableURL( + shell: shell, + useLoginShell: true + ) { + return executableURL + } + throw GithubCLIError.unavailable + } + + private func locateExecutableURL( + shell: ShellClient, + useLoginShell: Bool + ) async -> URL? { + let whichURL = URL(fileURLWithPath: "/usr/bin/which") + do { + let output: String + if useLoginShell { + output = try await shell.runLogin( + whichURL, + ["gh"], + nil, + log: false + ).stdout + } else { + output = try await shell.run(whichURL, ["gh"], nil).stdout + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return URL(fileURLWithPath: trimmed) + } catch { + return nil + } + } +} diff --git a/supacode/Clients/Github/GithubCLIModels.swift b/supacode/Clients/Github/GithubCLIModels.swift new file mode 100644 index 00000000..a6ff9c25 --- /dev/null +++ b/supacode/Clients/Github/GithubCLIModels.swift @@ -0,0 +1,90 @@ +import Foundation + +struct GithubAuthStatus: Equatable, Sendable { + let username: String + let host: String +} + +struct GithubAuthStatusResponse: Sendable { + let hosts: [String: [GithubAuthAccount]] + + struct GithubAuthAccount: Sendable { + let active: Bool + let login: String + } +} + +extension GithubAuthStatusResponse: Decodable { + private enum CodingKeys: String, CodingKey { + case hosts + } + + nonisolated init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.hosts = try container.decode([String: [GithubAuthAccount]].self, forKey: .hosts) + } +} + +extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { + private enum CodingKeys: String, CodingKey { + case active + case login + } + + nonisolated init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.active = try container.decode(Bool.self, forKey: .active) + self.login = try container.decode(String.self, forKey: .login) + } +} + +nonisolated struct RepoKey: Hashable, Sendable { + let owner: String + let repo: String +} + +nonisolated struct CrossRepoPullRequestRequest: Sendable, Hashable { + let owner: String + let repo: String + let branches: [String] + + var key: RepoKey { + RepoKey(owner: owner, repo: repo) + } +} + +nonisolated struct CrossRepoPullRequestResult: Sendable { + let successByRepo: [RepoKey: [String: GithubPullRequest]] + let failedRepos: [RepoKey: GithubCLIError] + + init( + successByRepo: [RepoKey: [String: GithubPullRequest]] = [:], + failedRepos: [RepoKey: GithubCLIError] = [:] + ) { + self.successByRepo = successByRepo + self.failedRepos = failedRepos + } +} + +struct GithubPullRequestsRequest: Sendable { + let host: String + let owner: String + let repo: String +} + +nonisolated struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable { + let owner: Owner + let name: String + let url: String + + nonisolated var remoteInfo: GithubRemoteInfo? { + guard let host = URL(string: url)?.host else { + return nil + } + return GithubRemoteInfo(host: host, owner: owner.login, repo: name) + } + + nonisolated struct Owner: Decodable, Sendable { + let login: String + } +}