Skip to content

Commit

Permalink
Reduce number of shell commands to get git info and improve reliability
Browse files Browse the repository at this point in the history
  • Loading branch information
hampustagerud authored and nicklockwood committed Jun 9, 2024
1 parent 64030c3 commit 6a53fcc
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 122 deletions.
158 changes: 98 additions & 60 deletions Sources/GitHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,89 +31,127 @@

import Foundation

private func memoize<K, T>(_ 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
}
}

struct GitFileInfo {
var createdByName: String?
var createdByEmail: String?
var createdAt: Date?
}

struct GitHelpers {
var currentWorkingDirectory: URL?
enum GitHelpers {
static let getGitRoot: (URL) -> URL? = memoize({ $0.relativePath }) { url in
let dir = "git rev-parse --show-toplevel".shellOutput(cwd: url)

init(cwd: URL?) {
currentWorkingDirectory = cwd
}
guard let root = dir, FileManager.default.fileExists(atPath: root) else {
return nil
}

private var inGitRoot: Bool {
// Get current git repository top level directory
guard let root = "git rev-parse --show-toplevel"
.shellOutput(cwd: currentWorkingDirectory) else { return false }
// Make sure a valid URL was returned
guard let _ = URL(string: root) else { return false }
// Make sure an existing path was returned
return FileManager.default.fileExists(atPath: root)
return URL(fileURLWithPath: root, isDirectory: true)
}

// If a file has never been committed, defaults to the local git user for that repository
private var defaultGitInfo: GitFileInfo? {
guard inGitRoot else { return nil }

let name = "git config user.name"
.shellOutput(cwd: currentWorkingDirectory)
let email = "git config user.email"
.shellOutput(cwd: currentWorkingDirectory)
// If a file has never been committed, default to the local git user for the repository
static 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)

guard let safeName = name, let safeEmail = email else { return nil }

return GitFileInfo(createdByName: safeName, createdByEmail: safeEmail)
}

private enum FileInfoPart: String {
case email = "ae"
case name = "an"
case createdAt = "at"
}

private func fileInfoPart(_ inputURL: URL,
_ part: FileInfoPart,
follow: Bool) -> String?
{
// --follow to keep tracking the file across renames
let follow = follow ? "--follow" : ""
let format = part.rawValue
let path = inputURL.relativePath

let value = "git log \(follow) --diff-filter=A --pretty=%\(format) \(path)"
.shellOutput(cwd: currentWorkingDirectory)
private static func getGitCommit(_ url: URL, root: URL, follow: Bool) -> String? {
let command = [
"git log",
// --follow to keep tracking the file across renames
follow ? "--follow" : "",
"--diff-filter=A",
"--author-date-order",
"--pretty=%H",
url.relativePath,
]
.filter { ($0?.count ?? 0) > 0 }
.joined(separator: " ")

let output = command.shellOutput(cwd: root)

guard let safeValue = output, !safeValue.isEmpty else { return nil }

if safeValue.contains("\n") {
let parts = safeValue.split(separator: "\n")

if parts.count > 1, let first = parts.first {
return String(first)
}
}

guard let safeValue = value, !safeValue.isEmpty else { return nil }
return safeValue
}

func fileInfo(_ inputURL: URL, follow: Bool) -> GitFileInfo? {
guard inGitRoot else { return nil }
static var json: JSONDecoder { JSONDecoder() }

static let getCommitInfo: ((String, URL)) -> GitFileInfo? = memoize(
{ hash, root in hash + root.relativePath },
{ hash, root in
let format = #"{"name":"%an","email":"%ae","time":"%at"}"#
let command = "git show --format='\(format)' -s \(hash)"
guard let commitInfo = command.shellOutput(cwd: root) else {
return nil
}

guard let commitData = commitInfo.data(using: .utf8) else {
return nil
}

let MapType = [String: String].self
guard let dict = try? json.decode(MapType, from: commitData) else {
return nil
}

let (name, email) = (dict["name"], dict["email"])

var date: Date?
if let createdAtString = dict["time"],
let interval = TimeInterval(createdAtString)
{
date = Date(timeIntervalSince1970: interval)
}

return GitFileInfo(createdByName: name,
createdByEmail: email,
createdAt: date)
}
)

let name = fileInfoPart(inputURL, .name, follow: follow) ??
defaultGitInfo?.createdByName
let email = fileInfoPart(inputURL, .email, follow: follow) ??
defaultGitInfo?.createdByEmail
static func fileInfo(_ url: URL, follow: Bool = false) -> GitFileInfo? {
let dir = url.deletingLastPathComponent()
guard let gitRoot = getGitRoot(dir) else { return nil }

var date: Date?
if let createdAtString = fileInfoPart(inputURL, .createdAt, follow: follow),
let interval = TimeInterval(createdAtString)
{
date = Date(timeIntervalSince1970: interval)
guard let commitHash = getGitCommit(url, root: gitRoot, follow: follow) else {
return nil
}

return GitFileInfo(createdByName: name,
createdByEmail: email,
createdAt: date)
}

static func fileInfo(_ inputURL: URL,
cwd: URL? = nil,
follow: Bool = false) -> GitFileInfo?
{
GitHelpers(cwd: cwd).fileInfo(inputURL, follow: follow)
return getCommitInfo((commitHash, gitRoot))
}
}
42 changes: 25 additions & 17 deletions Sources/Options.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,16 @@ public enum HeaderStrippingMode: Equatable, RawRepresentable, ExpressibleByStrin

return keys.contains(where: { str.contains("{\($0.rawValue)}") })
}

public var needsGitInfo: Bool {
hasTemplateKey(.createdDate, .createdYear,
.createdName, .createdEmail)
}

public var needsFollowGitInfo: Bool {
hasTemplateKey(.followedCreatedDate, .followedCreatedYear,
.followedCreatedName, .followedCreatedEmail)
}
}

public struct ReplacementOptions {
Expand Down Expand Up @@ -308,23 +318,21 @@ public enum ReplacementType: Equatable {

/// File info, used for constructing header comments
public struct FileInfo: Equatable, CustomStringConvertible {
static var defaultReplacements: [ReplacementKey: ReplacementType] {
[
.createdDate: .dynamic { info, options in
info.creationDate?.format(with: options.dateFormat,
timeZone: options.timeZone)
},
.createdYear: .dynamic { info, _ in info.creationDate?.yearString },
.followedCreatedDate: .dynamic { info, options in
info.followedCreationDate?.format(with: options.dateFormat,
timeZone: options.timeZone)
},
.followedCreatedYear: .dynamic { info, _ in
info.followedCreationDate?.yearString
},
.currentYear: .constant(Date.currentYear),
]
}
static var defaultReplacements: [ReplacementKey: ReplacementType] = [
.createdDate: .dynamic { info, options in
info.creationDate?.format(with: options.dateFormat,
timeZone: options.timeZone)
},
.createdYear: .dynamic { info, _ in info.creationDate?.yearString },
.followedCreatedDate: .dynamic { info, options in
info.followedCreationDate?.format(with: options.dateFormat,
timeZone: options.timeZone)
},
.followedCreatedYear: .dynamic { info, _ in
info.followedCreationDate?.yearString
},
.currentYear: .constant(Date.currentYear),
]

let filePath: String?
var creationDate: Date?
Expand Down
29 changes: 12 additions & 17 deletions Sources/SwiftFormat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,24 +180,18 @@ public func enumerateFiles(withInputURL inputURL: URL,
let fileOptions = options.fileOptions ?? .default
if resourceValues.isRegularFile == true {
if fileOptions.supportedFileExtensions.contains(inputURL.pathExtension) {
let shouldGetGitInfo =
options.rules?.contains(FormatRules.fileHeader.name) ?? false &&
options.formatOptions?.fileHeader.hasTemplateKey(
.createdName,
.createdEmail,
.createdDate,
.createdYear,
.followedCreatedName,
.followedCreatedEmail,
.followedCreatedDate,
.followedCreatedYear
) ?? false
let fileHeaderRuleEnabled = options.rules?.contains(FormatRules.fileHeader.name) ?? false
let shouldGetGitInfo = fileHeaderRuleEnabled &&
options.formatOptions?.fileHeader.needsGitInfo == true

let shouldGetFollowGitInfo = fileHeaderRuleEnabled &&
options.formatOptions?.fileHeader.needsFollowGitInfo == true

let gitInfo = shouldGetGitInfo
? GitHelpers.fileInfo(inputURL)
: nil

let followedGitInfo = shouldGetGitInfo && gitInfo != nil
let followedGitInfo = shouldGetFollowGitInfo
? GitHelpers.fileInfo(inputURL, follow: true)
: nil

Expand All @@ -206,12 +200,13 @@ public func enumerateFiles(withInputURL inputURL: URL,
creationDate: gitInfo?.createdAt ?? resourceValues.creationDate,
followedCreationDate: followedGitInfo?.createdAt,
replacements: [
.createdName: .init(gitInfo?.createdByName),
.createdEmail: .init(gitInfo?.createdByEmail),
.followedCreatedName: .init(followedGitInfo?.createdByName),
.followedCreatedEmail: .init(followedGitInfo?.createdByEmail),
.createdName: ReplacementType(gitInfo?.createdByName),
.createdEmail: ReplacementType(gitInfo?.createdByEmail),
.followedCreatedName: ReplacementType(followedGitInfo?.createdByName),
.followedCreatedEmail: ReplacementType(followedGitInfo?.createdByEmail),
].compactMapValues { $0 }
)

var options = options
options.formatOptions?.fileInfo = fileInfo
do {
Expand Down
38 changes: 10 additions & 28 deletions Tests/RulesTests+General.swift
Original file line number Diff line number Diff line change
Expand Up @@ -749,34 +749,16 @@ class GeneralTests: RulesTests {
])
}

func testGitHelpersReturnsInfo() {
let projectDirectory = URL(fileURLWithPath: #file)
.deletingLastPathComponent().deletingLastPathComponent()
let dateFormat = DateFormat.custom("yyyy-MM-dd HH:mm:ss ZZZZZ")

let info = GitHelpers(cwd: projectDirectory)
.fileInfo(URL(fileURLWithPath: #file), follow: false)

XCTAssertEqual(info?.createdByName, "Nick Lockwood")
XCTAssertEqual(info?.createdByEmail, "[email protected]")
let formattedDate = info?.createdAt?.format(with: dateFormat,
timeZone: .identifier("UTC"))
XCTAssertEqual(formattedDate, "2021-09-28 14:23:05 Z")
}

func testGitHelpersReturnsFollowInfo() {
let projectDirectory = URL(fileURLWithPath: #file)
.deletingLastPathComponent().deletingLastPathComponent()
let dateFormat = DateFormat.custom("yyyy-MM-dd HH:mm:ss ZZZZZ")

let info = GitHelpers(cwd: projectDirectory)
.fileInfo(URL(fileURLWithPath: #file), follow: true)

XCTAssertEqual(info?.createdByName, "Nick Lockwood")
XCTAssertEqual(info?.createdByEmail, "[email protected]")
let formattedDate = info?.createdAt?.format(with: dateFormat,
timeZone: .identifier("UTC"))
XCTAssertEqual(formattedDate, "2016-08-22 19:41:41 Z")
func testGitHelpersReturnsInfo() {
let info = GitHelpers.fileInfo(URL(fileURLWithPath: #file), follow: false)
XCTAssertNotNil(info?.createdByName)
XCTAssertNotNil(info?.createdByEmail)
XCTAssertNotNil(info?.createdAt)

let followInfo = GitHelpers.fileInfo(URL(fileURLWithPath: #file), follow: true)
XCTAssertNotNil(followInfo?.createdByName)
XCTAssertNotNil(followInfo?.createdByEmail)
XCTAssertNotNil(followInfo?.createdAt)
}

func testFileHeaderRuleThrowsIfCreationDateUnavailable() {
Expand Down

0 comments on commit 6a53fcc

Please sign in to comment.