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
43 changes: 43 additions & 0 deletions macgit/Models/GitCredentialHelperMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
}
}
}
}
4 changes: 3 additions & 1 deletion macgit/Models/GlobalGitSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -35,6 +36,7 @@ struct GlobalGitSettings: Equatable, Sendable {
defaultBranchName: "main",
pruneOnFetch: false,
autoSetupRemote: false,
excludesFilePath: "~/.config/git/ignore"
excludesFilePath: "~/.config/git/ignore",
credentialHelperValues: []
)
}
10 changes: 10 additions & 0 deletions macgit/Services/GitCredentialInjector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {})
}
Comment thread
Tranthanh98 marked this conversation as resolved.
}

protocol GitCredentialInjecting {
Expand Down
135 changes: 133 additions & 2 deletions macgit/Services/GitStatusService+GlobalSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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],
Expand All @@ -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
Expand All @@ -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
)
}
}
}
4 changes: 2 additions & 2 deletions macgit/Services/GitStatusService+Remote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
Expand Down
4 changes: 2 additions & 2 deletions macgit/Services/GitStatusService+RemoteCredential.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -58,7 +58,7 @@ extension GitStatusService {
for: remoteURLString,
credentialResolver: credentialResolver
) else {
return nil
return .configuredGitHelpers()
}
var result = try injection(
for: credential,
Expand Down
4 changes: 2 additions & 2 deletions macgit/Services/GitStatusService+Submodule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"],
Expand Down
Loading
Loading