diff --git a/macgit/Models/GitCredentialHelperMode.swift b/macgit/Models/GitCredentialHelperMode.swift new file mode 100644 index 0000000..e546b0d --- /dev/null +++ b/macgit/Models/GitCredentialHelperMode.swift @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +enum GitCredentialHelperMode: Hashable, Identifiable, Sendable { + case commitPlusAccountsOnly + case helper(String) + case preserveExisting + + var id: String { + switch self { + case .commitPlusAccountsOnly: + "commit-plus-accounts-only" + case .helper(let name): + "helper:\(name)" + case .preserveExisting: + "preserve-existing" + } + } + + static func resolve(configuredValues: [String]) -> Self { + let effectiveValues = effectiveValues(configuredValues: configuredValues) + + switch effectiveValues.count { + case 0: + return .commitPlusAccountsOnly + case 1: + return .helper(effectiveValues[0]) + default: + return .preserveExisting + } + } + + static func effectiveValues(configuredValues: [String]) -> [String] { + configuredValues.reduce(into: [String]()) { result, value in + if value.isEmpty { + result.removeAll() + } else { + result.append(value) + } + } + } +} diff --git a/macgit/Models/GlobalGitSettings.swift b/macgit/Models/GlobalGitSettings.swift index 585fd1f..0048965 100644 --- a/macgit/Models/GlobalGitSettings.swift +++ b/macgit/Models/GlobalGitSettings.swift @@ -26,6 +26,7 @@ struct GlobalGitSettings: Equatable, Sendable { var pruneOnFetch: Bool var autoSetupRemote: Bool var excludesFilePath: String + var credentialHelperValues: [String] = [] static let empty = GlobalGitSettings( executablePath: "", @@ -35,6 +36,7 @@ struct GlobalGitSettings: Equatable, Sendable { defaultBranchName: "main", pruneOnFetch: false, autoSetupRemote: false, - excludesFilePath: "~/.config/git/ignore" + excludesFilePath: "~/.config/git/ignore", + credentialHelperValues: [] ) } diff --git a/macgit/Services/GitCredentialInjector.swift b/macgit/Services/GitCredentialInjector.swift index bf66a7f..efd6f71 100644 --- a/macgit/Services/GitCredentialInjector.swift +++ b/macgit/Services/GitCredentialInjector.swift @@ -31,6 +31,16 @@ struct GitSSHCredential: Equatable { struct GitCredentialInjection { var environment: [String: String] var cleanup: () -> Void + + static func configuredGitHelpers( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Self { + var environment = environment + environment["GIT_TERMINAL_PROMPT"] = "0" + environment["GIT_ASKPASS"] = "" + environment["SSH_ASKPASS"] = nil + return GitCredentialInjection(environment: environment, cleanup: {}) + } } protocol GitCredentialInjecting { diff --git a/macgit/Services/GitStatusService+GlobalSettings.swift b/macgit/Services/GitStatusService+GlobalSettings.swift index 8970954..2e5b6fb 100644 --- a/macgit/Services/GitStatusService+GlobalSettings.swift +++ b/macgit/Services/GitStatusService+GlobalSettings.swift @@ -32,11 +32,15 @@ extension GitStatusService { pruneOnFetch: await globalConfigBool("fetch.prune", in: directory), autoSetupRemote: await globalConfigBool("push.autoSetupRemote", in: directory), excludesFilePath: await globalConfigValue("core.excludesFile", in: directory) - ?? "~/.config/git/ignore" + ?? "~/.config/git/ignore", + credentialHelperValues: await globalConfigValues("credential.helper", in: directory) ) } - func updateGlobalGitSettings(_ settings: GlobalGitSettings) async throws { + func updateGlobalGitSettings( + _ settings: GlobalGitSettings, + credentialHelperMode: GitCredentialHelperMode? = nil + ) async throws { let directory = FileManager.default.homeDirectoryForCurrentUser let name = settings.userName.trimmingCharacters(in: .whitespacesAndNewlines) let email = settings.userEmail.trimmingCharacters(in: .whitespacesAndNewlines) @@ -54,6 +58,9 @@ extension GitStatusService { } _ = try await runGit(arguments: ["check-ref-format", "--branch", branch], in: directory) + if let credentialHelperMode { + try await updateGlobalCredentialHelper(credentialHelperMode, in: directory) + } try await setGlobalConfig("user.name", value: name, in: directory) try await setGlobalConfig("user.email", value: email, in: directory) try await setGlobalConfig("init.defaultBranch", value: branch, in: directory) @@ -74,6 +81,28 @@ extension GitStatusService { } } + func availableCredentialHelpers() async -> [String] { + let directory = FileManager.default.homeDirectoryForCurrentUser + guard let output = try? await runGit(arguments: ["help", "-a"], in: directory) else { + return [] + } + + let helpers = Set(output.split(whereSeparator: \Character.isWhitespace).compactMap { token -> String? in + let command = String(token) + let prefix = "credential-" + guard command.hasPrefix(prefix) else { return nil } + let name = String(command.dropFirst(prefix.count)) + guard !name.isEmpty, !name.contains("--"), name != "store" else { return nil } + return name + }) + + return helpers.sorted { lhs, rhs in + if lhs == "osxkeychain" { return true } + if rhs == "osxkeychain" { return false } + return lhs.localizedStandardCompare(rhs) == .orderedAscending + } + } + private func globalConfigValue(_ key: String, in directory: URL) async -> String? { let value = try? await runGit( arguments: ["config", "--global", "--get", key], @@ -84,6 +113,38 @@ extension GitStatusService { return trimmed } + private func globalConfigValues(_ key: String, in directory: URL) async -> [String] { + guard let output = try? await runGit( + arguments: ["config", "--global", "--get-all", key], + in: directory + ), !output.isEmpty else { + return [] + } + + return configValues(from: output) + } + + private func requiredGlobalConfigValues(_ key: String, in directory: URL) async throws -> [String] { + do { + let output = try await runGit( + arguments: ["config", "--global", "--get-all", key], + in: directory + ) + return configValues(from: output) + } catch GitError.commandFailed(let message) where message.isEmpty { + return [] + } + } + + private func configValues(from output: String) -> [String] { + guard !output.isEmpty else { return [] } + var values = output.components(separatedBy: "\n") + if values.last == "" { + values.removeLast() + } + return values + } + private func globalConfigBool(_ key: String, in directory: URL) async -> Bool { guard let value = await globalConfigValue(key, in: directory)?.lowercased() else { return false @@ -97,4 +158,74 @@ extension GitStatusService { in: directory ) } + + private func updateGlobalCredentialHelper( + _ mode: GitCredentialHelperMode, + in directory: URL + ) async throws { + switch mode { + case .preserveExisting: + return + case .helper(let helper): + let availableHelpers = await availableCredentialHelpers() + guard availableHelpers.contains(helper) else { + throw GitError.commandFailed( + "The credential helper '\(helper)' is not available to the active Git runtime." + ) + } + case .commitPlusAccountsOnly: + break + } + + let previousValues = try await requiredGlobalConfigValues("credential.helper", in: directory) + _ = try await runGit( + arguments: ["config", "--global", "--replace-all", "credential.helper", ""], + in: directory + ) + + do { + if case .helper(let helper) = mode { + _ = try await runGit( + arguments: ["config", "--global", "--add", "credential.helper", helper], + in: directory + ) + } + + let savedValues = try await requiredGlobalConfigValues("credential.helper", in: directory) + guard GitCredentialHelperMode.resolve(configuredValues: savedValues) == mode else { + throw GitError.commandFailed("Git did not save the selected credential helper configuration.") + } + } catch { + let replacementError = error + do { + try await restoreGlobalCredentialHelpers(previousValues, in: directory) + } catch { + throw GitError.commandFailed( + "\(replacementError.localizedDescription) Restoring the previous credential helpers also failed: \(error.localizedDescription)" + ) + } + throw replacementError + } + } + + private func restoreGlobalCredentialHelpers(_ values: [String], in directory: URL) async throws { + guard let first = values.first else { + _ = try await runGit( + arguments: ["config", "--global", "--unset-all", "credential.helper"], + in: directory + ) + return + } + + _ = try await runGit( + arguments: ["config", "--global", "--replace-all", "credential.helper", first], + in: directory + ) + for value in values.dropFirst() { + _ = try await runGit( + arguments: ["config", "--global", "--add", "credential.helper", value], + in: directory + ) + } + } } diff --git a/macgit/Services/GitStatusService+Remote.swift b/macgit/Services/GitStatusService+Remote.swift index cda6cbf..257b1fb 100644 --- a/macgit/Services/GitStatusService+Remote.swift +++ b/macgit/Services/GitStatusService+Remote.swift @@ -441,7 +441,7 @@ extension GitStatusService { credentialInjector: GitCredentialInjecting, sshCredentialInjector: GitSSHCredentialInjecting ) async throws -> GitCredentialInjection? { - guard let credentialResolver else { return nil } + guard let credentialResolver else { return .configuredGitHelpers() } let remoteNames = await remotes(in: repositoryURL) let credentials = try await remoteNames.asyncCompactMap { remote -> RemoteGitCredential? in let remoteURLString = await remoteURL(remote: remote, in: repositoryURL) @@ -452,7 +452,7 @@ extension GitStatusService { result.append(credential) } } - guard let credential = uniqueCredentials.first else { return nil } + guard let credential = uniqueCredentials.first else { return .configuredGitHelpers() } guard uniqueCredentials.count == 1 else { throw GitProviderCredentialError.multipleMatchingAccounts(host: "configured remotes") } diff --git a/macgit/Services/GitStatusService+RemoteCredential.swift b/macgit/Services/GitStatusService+RemoteCredential.swift index fa5bbc0..729e701 100644 --- a/macgit/Services/GitStatusService+RemoteCredential.swift +++ b/macgit/Services/GitStatusService+RemoteCredential.swift @@ -41,7 +41,7 @@ extension GitStatusService { credentialInjector: GitCredentialInjecting, sshCredentialInjector: GitSSHCredentialInjecting ) async throws -> GitCredentialInjection? { - guard let credentialResolver else { return nil } + guard let credentialResolver else { return .configuredGitHelpers() } let remoteURLString: String if GitRemoteIdentityResolver.identity( from: remote, @@ -58,7 +58,7 @@ extension GitStatusService { for: remoteURLString, credentialResolver: credentialResolver ) else { - return nil + return .configuredGitHelpers() } var result = try injection( for: credential, diff --git a/macgit/Services/GitStatusService+Submodule.swift b/macgit/Services/GitStatusService+Submodule.swift index ef5fff9..539e30f 100644 --- a/macgit/Services/GitStatusService+Submodule.swift +++ b/macgit/Services/GitStatusService+Submodule.swift @@ -358,7 +358,7 @@ extension GitStatusService { in repositoryURL: URL, credentialResolver: GitProviderCredentialResolver? ) async throws -> GitCredentialInjection? { - guard credentialResolver != nil else { return nil } + guard credentialResolver != nil else { return .configuredGitHelpers() } let pathOutput = try await runGit( arguments: ["config", "--file", ".gitmodules", "--get-regexp", #"^submodule\..*\.path$"#], in: repositoryURL @@ -368,7 +368,7 @@ extension GitStatusService { guard fields.count == 2, fields[1] == Substring(path) else { return nil } return String(fields[0].dropLast(".path".count)) }).first else { - return nil + return .configuredGitHelpers() } let remoteURL = try await runGit( arguments: ["config", "--file", ".gitmodules", "--get", "\(key).url"], diff --git a/macgit/ViewModels/GitSettingsViewModel.swift b/macgit/ViewModels/GitSettingsViewModel.swift index 8332619..8771586 100644 --- a/macgit/ViewModels/GitSettingsViewModel.swift +++ b/macgit/ViewModels/GitSettingsViewModel.swift @@ -24,6 +24,7 @@ import Observation final class GitSettingsViewModel { var settings = GlobalGitSettings.empty var selectedRuntimePreference = GitRuntimePreference.automatic + var credentialHelperMode = GitCredentialHelperMode.commitPlusAccountsOnly private(set) var isLoading = false private(set) var isSaving = false private(set) var isDownloadingEmbeddedGit = false @@ -34,8 +35,11 @@ final class GitSettingsViewModel { private(set) var activeRuntime: GitRuntimeInstallation? private(set) var embeddedVersion = "" private(set) var embeddedDownloadSize = 0 + private(set) var availableCredentialHelpers: [String] = [] + private(set) var savedCredentialHelperMode = GitCredentialHelperMode.commitPlusAccountsOnly var errorMessage: String? var showingError = false + var showingCredentialHelperReplacementConfirmation = false @ObservationIgnored private let service: GitStatusService @ObservationIgnored private let runtimeManager: GitRuntimeManager @@ -52,7 +56,7 @@ final class GitSettingsViewModel { } var canSave: Bool { - settings != savedSettings && !isBusy + (settings != savedSettings || credentialHelperMode != savedCredentialHelperMode) && !isBusy } var isBusy: Bool { @@ -100,6 +104,45 @@ final class GitSettingsViewModel { && (selectedRuntimePreference == .embedded || activeRuntime == nil) } + var credentialHelperChoices: [String] { + var choices = availableCredentialHelpers + if case .helper(let helper) = credentialHelperMode, !choices.contains(helper) { + choices.append(helper) + } + return choices + } + + var hasMultipleCredentialHelpers: Bool { + savedCredentialHelperMode == .preserveExisting + } + + var selectedCredentialHelperIsInsecure: Bool { + guard case .helper(let helper) = credentialHelperMode else { return false } + return isInsecureCredentialStore(helper) + } + + var configuredCredentialsUseInsecureStore: Bool { + selectedCredentialHelperIsInsecure + || GitCredentialHelperMode.effectiveValues( + configuredValues: settings.credentialHelperValues + ).contains(where: isInsecureCredentialStore) + } + + var credentialHelperAvailability: (title: String, systemImage: String, isAvailable: Bool)? { + guard case .helper(let helper) = credentialHelperMode else { return nil } + return availability(for: helper) + } + + func availability(for helper: String) -> (title: String, systemImage: String, isAvailable: Bool) { + if availableCredentialHelpers.contains(helper) { + return ("Available to \(visibleRuntimeTitle)", "checkmark.circle.fill", true) + } + if helper.hasPrefix("!") || helper.hasPrefix("/") || helper.contains(where: \Character.isWhitespace) { + return ("Custom helper; availability is not verified", "questionmark.circle", false) + } + return ("Not available to \(visibleRuntimeTitle)", "exclamationmark.triangle.fill", false) + } + func load() async { isLoading = true statusMessage = nil @@ -111,20 +154,45 @@ final class GitSettingsViewModel { let loaded = try await service.loadGlobalGitSettings() settings = loaded savedSettings = loaded + credentialHelperMode = GitCredentialHelperMode.resolve( + configuredValues: loaded.credentialHelperValues + ) + savedCredentialHelperMode = credentialHelperMode + availableCredentialHelpers = await service.availableCredentialHelpers() } catch { present(error) } } - func save() async { + func save(allowCredentialHelperReplacement: Bool = false) async { guard canSave else { return } + if hasMultipleCredentialHelpers, + credentialHelperMode != .preserveExisting, + !allowCredentialHelperReplacement { + showingCredentialHelperReplacementConfirmation = true + return + } isSaving = true statusMessage = nil defer { isSaving = false } do { - try await service.updateGlobalGitSettings(settings) - savedSettings = settings + let changedCredentialHelperMode = credentialHelperMode == savedCredentialHelperMode + ? nil + : credentialHelperMode + try await service.updateGlobalGitSettings( + settings, + credentialHelperMode: changedCredentialHelperMode + ) + let loaded = try await service.loadGlobalGitSettings() + settings = loaded + savedSettings = loaded + credentialHelperMode = GitCredentialHelperMode.resolve( + configuredValues: loaded.credentialHelperValues + ) + savedCredentialHelperMode = credentialHelperMode + availableCredentialHelpers = await service.availableCredentialHelpers() + showingCredentialHelperReplacementConfirmation = false statusMessage = "Global Git settings saved." } catch { present(error) @@ -143,6 +211,7 @@ final class GitSettingsViewModel { try await runtimeManager.setPreference(preference) await refreshRuntimeStatus() applyActiveRuntimeToSettings() + availableCredentialHelpers = await service.availableCredentialHelpers() statusMessage = "\(preference.title) is now active." } catch { await refreshRuntimeStatus() @@ -161,6 +230,7 @@ final class GitSettingsViewModel { try await runtimeManager.setPreference(.embedded) await refreshRuntimeStatus() applyActiveRuntimeToSettings() + availableCredentialHelpers = await service.availableCredentialHelpers() statusMessage = "Embedded Git \(embeddedVersion) installed and selected." } catch { await refreshRuntimeStatus() @@ -235,6 +305,10 @@ final class GitSettingsViewModel { showingError = true } + private func isInsecureCredentialStore(_ helper: String) -> Bool { + helper.split(whereSeparator: \Character.isWhitespace).first == "store" + } + private func refreshRuntimeStatus() async { let status = await runtimeManager.status() selectedRuntimePreference = status.preference diff --git a/macgit/Views/Common/GitCredentialHelperSettingsSection.swift b/macgit/Views/Common/GitCredentialHelperSettingsSection.swift new file mode 100644 index 0000000..acb7d19 --- /dev/null +++ b/macgit/Views/Common/GitCredentialHelperSettingsSection.swift @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import SwiftUI + +struct GitCredentialHelperSettingsSection: View { + @Bindable var viewModel: GitSettingsViewModel + + var body: some View { + Section { + Picker("Fallback Credential Helper", selection: $viewModel.credentialHelperMode) { + Text("Commit+ connected accounts only") + .tag(GitCredentialHelperMode.commitPlusAccountsOnly) + + if viewModel.hasMultipleCredentialHelpers { + Text("Keep existing helper chain") + .tag(GitCredentialHelperMode.preserveExisting) + } + + ForEach(viewModel.credentialHelperChoices, id: \.self) { helper in + Text(helper == "osxkeychain" ? "macOS Keychain — Recommended" : helper) + .tag(GitCredentialHelperMode.helper(helper)) + } + } + .disabled(viewModel.isBusy) + + if viewModel.credentialHelperMode == .preserveExisting { + LabeledContent("Configured Helpers") { + VStack(alignment: .trailing) { + ForEach(Array(viewModel.settings.credentialHelperValues.enumerated()), id: \.offset) { _, helper in + if helper.isEmpty { + Label("Reset helper list", systemImage: "arrow.counterclockwise") + .foregroundStyle(.secondary) + } else { + let availability = viewModel.availability(for: helper) + Label(helper, systemImage: availability.systemImage) + .foregroundStyle(availability.isAvailable ? Color.green : Color.secondary) + .textSelection(.enabled) + } + } + } + } + } + + if let availability = viewModel.credentialHelperAvailability { + LabeledContent("Active Runtime") { + Label(availability.title, systemImage: availability.systemImage) + .foregroundStyle(availability.isAvailable ? .green : .secondary) + } + } + + if viewModel.configuredCredentialsUseInsecureStore { + Label( + "credential-store saves credentials unencrypted on disk. Choose macOS Keychain instead.", + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + } + } header: { + Label("Credential Helper", systemImage: "key") + } footer: { + Text( + "A matching Commit+ connected account is always used first. Otherwise HTTPS operations use this helper without falling back to a Terminal prompt. Changing this setting never deletes saved Keychain credentials." + ) + } + } +} diff --git a/macgit/Views/Common/GitSettingsView.swift b/macgit/Views/Common/GitSettingsView.swift index 436b3bd..bdcb481 100644 --- a/macgit/Views/Common/GitSettingsView.swift +++ b/macgit/Views/Common/GitSettingsView.swift @@ -26,6 +26,7 @@ struct GitSettingsView: View { Form { GitRuntimeSettingsSection(viewModel: viewModel) GitLFSRuntimeSection() + GitCredentialHelperSettingsSection(viewModel: viewModel) Section { TextField("Full Name", text: $viewModel.settings.userName) @@ -80,9 +81,7 @@ struct GitSettingsView: View { .controlSize(.small) } - Button("Save Git Settings") { - Task { await viewModel.save() } - } + Button("Save Git Settings", action: save) .buttonStyle(.borderedProminent) .disabled(!viewModel.canSave) } @@ -111,5 +110,25 @@ struct GitSettingsView: View { } message: { Text(viewModel.errorMessage ?? "An unknown error occurred.") } + .confirmationDialog( + "Replace existing credential helpers?", + isPresented: $viewModel.showingCredentialHelperReplacementConfirmation, + titleVisibility: .visible + ) { + Button("Replace Helpers", role: .destructive, action: replaceCredentialHelpers) + Button("Cancel", role: .cancel) {} + } message: { + Text( + "This global Git configuration contains multiple credential helpers. Commit+ will replace the helper chain, but it will not delete credentials stored by those helpers." + ) + } + } + + private func save() { + Task { await viewModel.save() } + } + + private func replaceCredentialHelpers() { + Task { await viewModel.save(allowCredentialHelperReplacement: true) } } } diff --git a/macgitTests/GitCredentialInjectorTests.swift b/macgitTests/GitCredentialInjectorTests.swift index 024f9b0..0cac510 100644 --- a/macgitTests/GitCredentialInjectorTests.swift +++ b/macgitTests/GitCredentialInjectorTests.swift @@ -28,6 +28,17 @@ final class GitCredentialInjectorTests: XCTestCase { XCTAssertNotNil(injection.environment["GIT_ASKPASS"]) } + func testConfiguredGitHelpersDisablePromptsWithoutDisablingHelpers() { + let injection = GitCredentialInjection.configuredGitHelpers( + environment: ["GIT_ASKPASS": "/tmp/git-askpass", "SSH_ASKPASS": "/tmp/ssh-askpass"] + ) + + XCTAssertEqual(injection.environment["GIT_TERMINAL_PROMPT"], "0") + XCTAssertEqual(injection.environment["GIT_ASKPASS"], "") + XCTAssertNil(injection.environment["SSH_ASKPASS"]) + XCTAssertNil(injection.environment["GIT_CONFIG_COUNT"]) + } + func testEnvironmentDisablesConfiguredCredentialHelpers() throws { let injection = try makeInjection() defer { injection.cleanup() } diff --git a/macgitTests/GitGlobalSettingsServiceTests.swift b/macgitTests/GitGlobalSettingsServiceTests.swift index 04c6053..501f98f 100644 --- a/macgitTests/GitGlobalSettingsServiceTests.swift +++ b/macgitTests/GitGlobalSettingsServiceTests.swift @@ -28,7 +28,8 @@ final class GitGlobalSettingsServiceTests: XCTestCase { ["config", "--global", "--get", "init.defaultBranch"]: "trunk\n", ["config", "--global", "--get", "fetch.prune"]: "true\n", ["config", "--global", "--get", "push.autoSetupRemote"]: "yes\n", - ["config", "--global", "--get", "core.excludesFile"]: "~/.gitignore_global\n" + ["config", "--global", "--get", "core.excludesFile"]: "~/.gitignore_global\n", + ["config", "--global", "--get-all", "credential.helper"]: "cache\nosxkeychain\n" ] ) let service = GitStatusService(runner: runner) @@ -43,6 +44,132 @@ final class GitGlobalSettingsServiceTests: XCTestCase { XCTAssertTrue(settings.pruneOnFetch) XCTAssertTrue(settings.autoSetupRemote) XCTAssertEqual(settings.excludesFilePath, "~/.gitignore_global") + XCTAssertEqual(settings.credentialHelperValues, ["cache", "osxkeychain"]) + } + + func testCredentialHelperModeUsesValuesAfterLastReset() { + XCTAssertEqual( + GitCredentialHelperMode.resolve(configuredValues: ["cache", "", "osxkeychain"]), + .helper("osxkeychain") + ) + XCTAssertEqual( + GitCredentialHelperMode.resolve(configuredValues: ["cache", "osxkeychain"]), + .preserveExisting + ) + XCTAssertEqual( + GitCredentialHelperMode.resolve(configuredValues: [""]), + .commitPlusAccountsOnly + ) + XCTAssertEqual( + GitCredentialHelperMode.effectiveValues( + configuredValues: ["store", "", "osxkeychain"] + ), + ["osxkeychain"] + ) + } + + func testAvailableCredentialHelpersUsesActiveGitCommandList() async { + let runner = GlobalSettingsRecordingRunner( + responses: [ + ["help", "-a"]: "credential-store credential-cache credential-cache--daemon credential-osxkeychain\n" + ] + ) + let service = GitStatusService(runner: runner) + + let helpers = await service.availableCredentialHelpers() + + XCTAssertEqual(helpers, ["osxkeychain", "cache"]) + } + + func testUpdateReplacesCredentialHelperWithResetAndVerifiedHelper() async throws { + let runner = GlobalSettingsRecordingRunner( + responses: [["help", "-a"]: "credential-osxkeychain\n"], + credentialHelpers: ["cache", "custom"] + ) + let service = GitStatusService(runner: runner) + let settings = makeValidSettings() + + try await service.updateGlobalGitSettings( + settings, + credentialHelperMode: .helper("osxkeychain") + ) + + let calls = await runner.recordedArguments() + XCTAssertTrue(calls.contains(["config", "--global", "--replace-all", "credential.helper", ""])) + XCTAssertTrue(calls.contains(["config", "--global", "--add", "credential.helper", "osxkeychain"])) + let credentialHelpers = await runner.currentCredentialHelpers() + XCTAssertEqual(credentialHelpers, ["", "osxkeychain"]) + } + + func testUpdateRestoresPreviousHelpersWhenAddingReplacementFails() async { + let failingArguments = ["config", "--global", "--add", "credential.helper", "osxkeychain"] + let runner = GlobalSettingsRecordingRunner( + responses: [["help", "-a"]: "credential-osxkeychain\n"], + credentialHelpers: ["cache", "custom"], + failOnceOnArguments: failingArguments + ) + let service = GitStatusService(runner: runner) + + do { + try await service.updateGlobalGitSettings( + makeValidSettings(), + credentialHelperMode: .helper("osxkeychain") + ) + XCTFail("Expected replacement to fail.") + } catch { + XCTAssertTrue(error.localizedDescription.contains("Simulated failure")) + } + + let credentialHelpers = await runner.currentCredentialHelpers() + XCTAssertEqual(credentialHelpers, ["cache", "custom"]) + } + + func testUpdateRejectsHelperMissingFromActiveRuntimeBeforeChangingConfiguration() async { + let runner = GlobalSettingsRecordingRunner( + responses: [["help", "-a"]: "credential-cache\n"], + credentialHelpers: ["cache"] + ) + let service = GitStatusService(runner: runner) + + do { + try await service.updateGlobalGitSettings( + makeValidSettings(), + credentialHelperMode: .helper("osxkeychain") + ) + XCTFail("Expected unavailable helper to be rejected.") + } catch { + XCTAssertTrue(error.localizedDescription.contains("not available")) + } + + let credentialHelpers = await runner.currentCredentialHelpers() + XCTAssertEqual(credentialHelpers, ["cache"]) + } + + func testUpdateClearsHelpersWithoutErasingStoredCredentials() async throws { + let runner = GlobalSettingsRecordingRunner(credentialHelpers: ["osxkeychain"]) + let service = GitStatusService(runner: runner) + + try await service.updateGlobalGitSettings( + makeValidSettings(), + credentialHelperMode: .commitPlusAccountsOnly + ) + + let credentialHelpers = await runner.currentCredentialHelpers() + let calls = await runner.recordedArguments() + XCTAssertEqual(credentialHelpers, [""]) + XCTAssertFalse(calls.flatMap { $0 }.contains("erase")) + } + + func testUpdatingOtherSettingsPreservesMultipleCredentialHelpers() async throws { + let runner = GlobalSettingsRecordingRunner(credentialHelpers: ["cache", "osxkeychain"]) + let service = GitStatusService(runner: runner) + + try await service.updateGlobalGitSettings(makeValidSettings()) + + let credentialHelpers = await runner.currentCredentialHelpers() + let calls = await runner.recordedArguments() + XCTAssertEqual(credentialHelpers, ["cache", "osxkeychain"]) + XCTAssertFalse(calls.contains(["config", "--global", "--unset-all", "credential.helper"])) } func testUpdateWritesValidatedGlobalConfiguration() async throws { @@ -93,22 +220,70 @@ final class GitGlobalSettingsServiceTests: XCTestCase { let calls = await runner.recordedArguments() XCTAssertTrue(calls.isEmpty) } + + private func makeValidSettings() -> GlobalGitSettings { + GlobalGitSettings( + executablePath: "/usr/bin/git", + version: "git version 2.50.1", + userName: "Ada Lovelace", + userEmail: "ada@example.com", + defaultBranchName: "main", + pruneOnFetch: true, + autoSetupRemote: false, + excludesFilePath: "~/.config/git/ignore" + ) + } } private actor GlobalSettingsRecordingRunner: GitCommandRunning { private let responses: [[String]: String] private var calls: [[String]] = [] + private var credentialHelpers: [String]? + private var failOnceOnArguments: [String]? - init(responses: [[String]: String] = [:]) { + init( + responses: [[String]: String] = [:], + credentialHelpers: [String]? = nil, + failOnceOnArguments: [String]? = nil + ) { self.responses = responses + self.credentialHelpers = credentialHelpers + self.failOnceOnArguments = failOnceOnArguments } func runGit(arguments: [String], in directory: URL) async throws -> String { calls.append(arguments) + if arguments == failOnceOnArguments { + failOnceOnArguments = nil + throw GitError.commandFailed("Simulated failure") + } + if arguments == ["config", "--global", "--get-all", "credential.helper"], + let credentialHelpers { + guard !credentialHelpers.isEmpty else { return "" } + return credentialHelpers.joined(separator: "\n") + "\n" + } + if arguments.starts(with: ["config", "--global", "--replace-all", "credential.helper"]), + let helper = arguments.last { + credentialHelpers = [helper] + return "" + } + if arguments == ["config", "--global", "--unset-all", "credential.helper"] { + credentialHelpers = [] + return "" + } + if arguments.starts(with: ["config", "--global", "--add", "credential.helper"]), + let helper = arguments.last { + credentialHelpers = (credentialHelpers ?? []) + [helper] + return "" + } return responses[arguments] ?? "" } func recordedArguments() -> [[String]] { calls } + + func currentCredentialHelpers() -> [String]? { + credentialHelpers + } } diff --git a/macgitTests/GitProviderCredentialResolverTests.swift b/macgitTests/GitProviderCredentialResolverTests.swift index 3df26a5..1361170 100644 --- a/macgitTests/GitProviderCredentialResolverTests.swift +++ b/macgitTests/GitProviderCredentialResolverTests.swift @@ -109,6 +109,49 @@ final class GitProviderCredentialResolverTests: XCTestCase { XCTAssertNil(credential) } + func testRemoteWithoutMatchingAccountAllowsConfiguredHelpersWithoutTerminalPrompt() async throws { + let resolver = GitProviderCredentialResolver( + accounts: [], + tokenVault: FakeCredentialTokenVault() + ) + + let injection = try await GitStatusService.shared.credentialInjection( + for: "https://github.com/octocat/Hello-World.git", + in: FileManager.default.temporaryDirectory, + credentialResolver: resolver, + credentialInjector: TemporaryGitCredentialInjector(), + sshCredentialInjector: TemporaryGitSSHCredentialInjector() + ) + defer { injection?.cleanup() } + + XCTAssertEqual(injection?.environment["GIT_TERMINAL_PROMPT"], "0") + XCTAssertEqual(injection?.environment["GIT_ASKPASS"], "") + XCTAssertNil(injection?.environment["SSH_ASKPASS"]) + XCTAssertNil(injection?.environment["GIT_CONFIG_COUNT"]) + } + + func testRemoteWithMatchingAccountUsesAppCredentialAndDisablesConfiguredHelpers() async throws { + let account = makeProviderAccount() + let resolver = GitProviderCredentialResolver( + accounts: [account], + tokenVault: FakeCredentialTokenVault(tokensByAccountID: [account.id: makeToken("secret")]) + ) + + let injection = try await GitStatusService.shared.credentialInjection( + for: "https://github.com/octocat/Hello-World.git", + in: FileManager.default.temporaryDirectory, + credentialResolver: resolver, + credentialInjector: TemporaryGitCredentialInjector(), + sshCredentialInjector: TemporaryGitSSHCredentialInjector() + ) + defer { injection?.cleanup() } + + XCTAssertNotNil(injection?.environment["GIT_ASKPASS"]) + let count = try XCTUnwrap(Int(try XCTUnwrap(injection?.environment["GIT_CONFIG_COUNT"]))) + XCTAssertEqual(injection?.environment["GIT_CONFIG_KEY_\(count - 1)"], "credential.helper") + XCTAssertEqual(injection?.environment["GIT_CONFIG_VALUE_\(count - 1)"], "") + } + func testMultipleMatchingAccountsThrows() async { let first = makeProviderAccount(id: "connection-1", username: "octocat") let second = makeProviderAccount(id: "connection-2", username: "monalisa") diff --git a/macgitTests/GitRemoteCredentialPolicyTests.swift b/macgitTests/GitRemoteCredentialPolicyTests.swift new file mode 100644 index 0000000..759f553 --- /dev/null +++ b/macgitTests/GitRemoteCredentialPolicyTests.swift @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +final class GitRemoteCredentialPolicyTests: XCTestCase { + func testFetchPullAndPushAllowConfiguredHelpersWithoutTerminalPrompt() async throws { + let runner = RecordingRunner() + let service = GitStatusService(runner: runner) + let repository = URL(fileURLWithPath: "/tmp/credential-policy-repository") + + try await service.fetch(remote: "origin", in: repository) + _ = try await service.pull( + remote: "origin", + branch: "main", + options: GitStatusService.PullOptions(), + in: repository + ) + _ = try await service.push( + options: GitStatusService.PushOptions(remote: "origin", branches: ["main"]), + in: repository + ) + + let calls = await runner.recordedCalls() + let remoteCalls = calls.filter { call in + ["fetch", "pull", "push"].contains(call.arguments.first) + } + XCTAssertEqual(remoteCalls.count, 3) + for call in remoteCalls { + let environment = try XCTUnwrap(call.environment) + XCTAssertEqual(environment["GIT_TERMINAL_PROMPT"], "0") + XCTAssertEqual(environment["GIT_ASKPASS"], "") + XCTAssertNil(environment["SSH_ASKPASS"]) + XCTAssertNil(environment["GIT_CONFIG_COUNT"]) + } + } + + private actor RecordingRunner: GitCommandRunning { + struct Call: Sendable { + let arguments: [String] + let environment: [String: String]? + } + + private var calls: [Call] = [] + + func runGit(arguments: [String], in directory: URL) async throws -> String { + calls.append(Call(arguments: arguments, environment: nil)) + return "" + } + + func runGit( + arguments: [String], + in directory: URL, + environment: [String: String] + ) async throws -> String { + calls.append(Call(arguments: arguments, environment: environment)) + return "" + } + + func recordedCalls() -> [Call] { + calls + } + } +}