// // GitFileInfo.swift // SwiftFormat // // Created by Hampus Tågerud on 2023-08-08. // Copyright 2023 Nick Lockwood and the SwiftFormat project authors // // Distributed under the permissive MIT license // Get the latest version from here: // // https://github.com/nicklockwood/SwiftFormat // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // import Foundation struct GitFileInfo { var authorName: String? var authorEmail: String? var creationDate: Date? } extension GitFileInfo { init?(url: URL) { guard let gitRoot = getGitRoot(url.deletingLastPathComponent()) else { return nil } let commitHash = getCommitHash(url, root: gitRoot) guard let gitInfo = getCommitInfo((commitHash, gitRoot)) else { return nil } self = gitInfo } var author: String? { if let authorName { if let authorEmail { return "\(authorName) <\(authorEmail)>" } return authorName } return authorEmail } } private extension String { func shellOutput(cwd: URL? = nil) -> String? { #if os(macOS) || os(Linux) || os(Windows) let process = Process() let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/bin/bash") process.arguments = ["-c", self] process.standardOutput = pipe process.standardError = pipe if let safeCWD = cwd { process.currentDirectoryURL = safeCWD } let file = pipe.fileHandleForReading do { try process.run() } catch { return nil } process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let outputData = file.readDataToEndOfFile() return String(data: outputData, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) #else return nil #endif } } private let getGitRoot: (URL) -> URL? = memoize({ $0.relativePath }) { url in let dir = "git rev-parse --show-toplevel".shellOutput(cwd: url) guard let root = dir, FileManager.default.fileExists(atPath: root) else { return nil } return URL(fileURLWithPath: root, isDirectory: true) } /// If a file has never been committed, default to the local git user for the repository private let getDefaultGitInfo: (URL) -> GitFileInfo = memoize({ $0.relativePath }) { url in let name = "git config user.name".shellOutput(cwd: url) let email = "git config user.email".shellOutput(cwd: url) return GitFileInfo(authorName: name, authorEmail: email) } private let getMovedFiles: (URL) -> [(from: URL, to: URL)] = memoize({ $0.relativePath }) { root in let command = "git diff --diff-filter=R --staged --name-status" guard let output = command.shellOutput(cwd: root) else { return [] } return output.components(separatedBy: "\n").compactMap { input -> (URL, URL)? in var parts = input.components(separatedBy: "\t").dropFirst() guard let from = parts.popFirst(), let to = parts.popFirst(), from != to else { return nil } return (URL(fileURLWithPath: from, relativeTo: root), URL(fileURLWithPath: to, relativeTo: root)) } } private func getCommitHash(_ url: URL, root: URL) -> String? { let movedFile = getMovedFiles(root).first { $0.to.absoluteString == url.absoluteString } let trackedFile = movedFile?.from ?? url let command = [ "git log", "--follow", // keep tracking file across renames "--diff-filter=A", "--author-date-order", "--pretty=%H", "--", "\"\(trackedFile.relativePath)\"", ] .joined(separator: " ") guard let output = command.shellOutput(cwd: root) else { return nil } return output.components(separatedBy: "\n").first.flatMap { $0.isEmpty ? nil : $0 } } private let getCommitInfo: ((String?, URL)) -> GitFileInfo? = memoize( { hash, root in (hash ?? "none") + root.relativePath }, { hash, root in let defaultInfo = getDefaultGitInfo(root) guard let hash else { return GitFileInfo(authorName: defaultInfo.authorName, authorEmail: defaultInfo.authorEmail) } let format = #"{"name":"%an","email":"%ae","time":"%at"}"# let command = "git show --format='\(format)' -s \(hash)" guard let commitInfo = command.shellOutput(cwd: root), let commitData = commitInfo.data(using: .utf8), let dict = try? JSONDecoder().decode([String: String].self, from: commitData) else { return nil } let (name, email) = ( dict["name"] ?? defaultInfo.authorName, dict["email"] ?? defaultInfo.authorEmail ) var date: Date? if let createdAtString = dict["time"], let interval = TimeInterval(createdAtString) { date = Date(timeIntervalSince1970: interval) } return GitFileInfo(authorName: name, authorEmail: email, creationDate: date) } ) private func memoize(_ keyFn: @escaping (K) -> String?, _ workFn: @escaping (K) -> T) -> (K) -> T { let lock = NSLock() var cache: [String: T] = [:] return { input in let key = keyFn(input) ?? "@nil" lock.lock() defer { lock.unlock() } if let value = cache[key] { return value } let newValue = workFn(input) cache[key] = newValue return newValue } }