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
30 changes: 18 additions & 12 deletions Sources/App/Commands/Analyze.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,9 @@ extension Analyze {
static func clone(cacheDir: String, url: String) async throws {
Current.logger().info("cloning \(url) to \(cacheDir)")
@Dependency(\.fileManager) var fileManager
try await Current.shell.run(command: .gitClone(url: URL(string: url)!, to: cacheDir),
at: fileManager.checkoutsDirectory())
@Dependency(\.shell) var shell
try await shell.run(command: .gitClone(url: URL(string: url)!, to: cacheDir),
at: fileManager.checkoutsDirectory())
}


Expand All @@ -269,22 +270,22 @@ extension Analyze {
/// - Throws: Shell errors
static func fetch(cacheDir: String, branch: String, url: String) async throws {
@Dependency(\.fileManager) var fileManager
@Dependency(\.shell) var shell
Current.logger().info("pulling \(url) in \(cacheDir)")
// clean up stray lock files that might have remained from aborted commands
for fileName in ["HEAD.lock", "index.lock"] {
let filePath = cacheDir + "/.git/\(fileName)"
if fileManager.fileExists(atPath: filePath) {
Current.logger().info("Removing stale \(fileName) at path: \(filePath)")
try await Current.shell.run(command: .removeFile(from: filePath))
try await shell.run(command: .removeFile(from: filePath), at: .cwd)
}
}
// git reset --hard to deal with stray .DS_Store files on macOS
try await Current.shell.run(command: .gitReset(hard: true), at: cacheDir)
try await Current.shell.run(command: .gitClean, at: cacheDir)
try await Current.shell.run(command: .gitFetchAndPruneTags, at: cacheDir)
try await Current.shell.run(command: .gitCheckout(branch: branch), at: cacheDir)
try await Current.shell.run(command: .gitReset(to: branch, hard: true),
at: cacheDir)
try await shell.run(command: .gitReset(hard: true), at: cacheDir)
try await shell.run(command: .gitClean, at: cacheDir)
try await shell.run(command: .gitFetchAndPruneTags, at: cacheDir)
try await shell.run(command: .gitCheckout(branch: branch), at: cacheDir)
try await shell.run(command: .gitReset(to: branch, hard: true), at: cacheDir)
}


Expand All @@ -293,6 +294,8 @@ extension Analyze {
/// - package: `Package` to refresh
static func refreshCheckout(package: Joined<Package, Repository>) async throws {
@Dependency(\.fileManager) var fileManager
@Dependency(\.shell) var shell

guard let cacheDir = fileManager.cacheDirectoryPath(for: package.model) else {
throw AppError.invalidPackageCachePath(package.model.id, package.model.url)
}
Expand All @@ -311,7 +314,7 @@ extension Analyze {
url: package.model.url)
} catch {
Current.logger().info("fetch failed: \(error.localizedDescription)")
try await Current.shell.run(command: .removeFile(from: cacheDir, arguments: ["-r", "-f"]))
try await shell.run(command: .removeFile(from: cacheDir, arguments: ["-r", "-f"]), at: .cwd)
try await clone(cacheDir: cacheDir, url: package.model.url)
}
} catch {
Expand Down Expand Up @@ -537,12 +540,13 @@ extension Analyze {
/// - Returns: `Manifest` data
static func dumpPackage(at path: String) async throws -> Manifest {
@Dependency(\.fileManager) var fileManager
@Dependency(\.shell) var shell
guard fileManager.fileExists(atPath: path + "/Package.swift") else {
// It's important to check for Package.swift - otherwise `dump-package` will go
// up the tree through parent directories to find one
throw AppError.invalidRevision(nil, "no Package.swift")
}
let json = try await Current.shell.run(command: .swiftDumpPackage, at: path)
let json = try await shell.run(command: .swiftDumpPackage, at: path)
return try JSONDecoder().decode(Manifest.self, from: Data(json.utf8))
}

Expand All @@ -561,12 +565,14 @@ extension Analyze {
static func getPackageInfo(package: Joined<Package, Repository>, version: Version) async throws -> PackageInfo {
// check out version in cache directory
@Dependency(\.fileManager) var fileManager
@Dependency(\.shell) var shell

guard let cacheDir = fileManager.cacheDirectoryPath(for: package.model) else {
throw AppError.invalidPackageCachePath(package.model.id,
package.model.url)
}

try await Current.shell.run(command: .gitCheckout(branch: version.reference.description), at: cacheDir)
try await shell.run(command: .gitCheckout(branch: version.reference.description), at: cacheDir)

do {
let packageManifest = try await dumpPackage(at: cacheDir)
Expand Down
31 changes: 1 addition & 30 deletions Sources/App/Core/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import FoundationNetworking
struct AppEnvironment: Sendable {
var logger: @Sendable () -> Logger
var setLogger: @Sendable (Logger) -> Void
var shell: Shell
}


Expand All @@ -34,35 +33,7 @@ extension AppEnvironment {

static let live = AppEnvironment(
logger: { logger },
setLogger: { logger in Self.logger = logger },
shell: .live
)
}



struct Shell: Sendable {
var run: @Sendable (ShellOutCommand, String) async throws -> String

// also provide pass-through methods to preserve argument labels
@discardableResult
func run(command: ShellOutCommand, at path: String = ".") async throws -> String {
do {
return try await run(command, path)
} catch {
// re-package error to capture more information
throw AppError.shellCommandFailed(command.description, path, error.localizedDescription)
}
}

static let live: Self = .init(
run: {
let res = try await ShellOut.shellOut(to: $0, at: $1, logger: Current.logger())
if !res.stderr.isEmpty {
Current.logger().warning("stderr: \(res.stderr)")
}
return res.stdout
}
setLogger: { logger in Self.logger = logger }
)
}

Expand Down
69 changes: 69 additions & 0 deletions Sources/App/Core/Dependencies/ShellClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Dependencies
import DependenciesMacros
import ShellOut


@DependencyClient
struct ShellClient {
var run: @Sendable (ShellOutCommand, String) async throws -> String
}


extension ShellClient {
@discardableResult
func run(command: ShellOutCommand, at path: String) async throws -> String {
try await run(command, path)
}
}


extension String {
static let cwd = "."
}


extension ShellClient: DependencyKey {
static var liveValue: Self {
.init(
run: { command, path in
do {
let res = try await ShellOut.shellOut(to: command, at: path, logger: Current.logger())
if !res.stderr.isEmpty {
Current.logger().warning("stderr: \(res.stderr)")
}
return res.stdout
} catch {
// re-package error to capture more information
throw AppError.shellCommandFailed(command.description, path, error.localizedDescription)
}
}
)
}
}


extension ShellClient: TestDependencyKey {
static var testValue: Self { .init() }
}


extension DependencyValues {
var shell: ShellClient {
get { self[ShellClient.self] }
set { self[ShellClient.self] = newValue }
}
}
23 changes: 16 additions & 7 deletions Sources/App/Core/Git.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
// limitations under the License.

import Foundation

import Dependencies
import SemanticVersion
import ShellOut

Expand All @@ -26,16 +28,18 @@ enum Git {
}

static func commitCount(at path: String) async throws -> Int {
let res = try await Current.shell.run(command: .gitCommitCount, at: path)
@Dependency(\.shell) var shell
let res = try await shell.run(command: .gitCommitCount, at: path)
guard let count = Int(res) else {
throw Error.invalidInteger
}
return count
}

static func firstCommitDate(at path: String) async throws -> Date {
@Dependency(\.shell) var shell
let res = String(
try await Current.shell.run(command: .gitFirstCommitDate, at: path)
try await shell.run(command: .gitFirstCommitDate, at: path)
.trimming { $0 == Character("\"") }
)
guard let timestamp = TimeInterval(res) else {
Expand All @@ -45,8 +49,9 @@ enum Git {
}

static func lastCommitDate(at path: String) async throws -> Date {
@Dependency(\.shell) var shell
let res = String(
try await Current.shell.run(command: .gitLastCommitDate, at: path)
try await shell.run(command: .gitLastCommitDate, at: path)
.trimming { $0 == Character("\"") }
)
guard let timestamp = TimeInterval(res) else {
Expand All @@ -56,27 +61,30 @@ enum Git {
}

static func getTags(at path: String) async throws -> [Reference] {
let tags = try await Current.shell.run(command: .gitListTags, at: path)
@Dependency(\.shell) var shell
let tags = try await shell.run(command: .gitListTags, at: path)
return tags.split(separator: "\n")
.map(String.init)
.compactMap { tag in SemanticVersion(tag).map { ($0, tag) } }
.map { Reference.tag($0, $1) }
}

static func hasBranch(_ reference: Reference, at path: String) async throws -> Bool {
@Dependency(\.shell) var shell
guard let branchName = reference.branchName else { return false }
do {
_ = try await Current.shell.run(command: .gitHasBranch(branchName), at: path)
_ = try await shell.run(command: .gitHasBranch(branchName), at: path)
return true
} catch {
return false
}
}

static func revisionInfo(_ reference: Reference, at path: String) async throws -> RevisionInfo {
@Dependency(\.shell) var shell
let separator = "-"
let res = String(
try await Current.shell.run(command: .gitRevisionInfo(reference: reference, separator: separator),
try await shell.run(command: .gitRevisionInfo(reference: reference, separator: separator),
at: path)
.trimming { $0 == Character("\"") }
)
Expand All @@ -92,7 +100,8 @@ enum Git {
}

static func shortlog(at path: String) async throws -> String {
try await Current.shell.run(command: .gitShortlog, at: path)
@Dependency(\.shell) var shell
return try await shell.run(command: .gitShortlog, at: path)
}

struct RevisionInfo: Equatable {
Expand Down
14 changes: 5 additions & 9 deletions Tests/AppTests/AnalyzeErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ final class AnalyzeErrorTests: AppTestCase {
Repository(package: pkgs[0], defaultBranch: "main", name: "1", owner: "foo"),
Repository(package: pkgs[1], defaultBranch: "main", name: "2", owner: "foo"),
].save(on: app.db)

Current.shell.run = Self.defaultShellRun
}

override func invokeTest() {
Expand Down Expand Up @@ -106,6 +104,7 @@ final class AnalyzeErrorTests: AppTestCase {
$0.httpClient.mastodonPost = { @Sendable [socialPosts = self.socialPosts] message in
socialPosts.withValue { $0.append(message) }
}
$0.shell.run = Self.defaultShellRun
} operation: {
super.invokeTest()
}
Expand All @@ -115,8 +114,7 @@ final class AnalyzeErrorTests: AppTestCase {
try await withDependencies {
$0.environment.loadSPIManifest = { _ in nil }
$0.fileManager.fileExists = { @Sendable _ in true }
} operation: {
Current.shell.run = { @Sendable cmd, path in
$0.shell.run = { @Sendable cmd, path in
switch cmd {
case _ where cmd.description.contains("git clone https://github.com/foo/1"):
throw SimulatedError()
Expand All @@ -128,7 +126,7 @@ final class AnalyzeErrorTests: AppTestCase {
return try Self.defaultShellRun(cmd, path)
}
}

} operation: {
// MUT
try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10))

Expand Down Expand Up @@ -172,9 +170,7 @@ final class AnalyzeErrorTests: AppTestCase {
try await withDependencies {
$0.environment.loadSPIManifest = { _ in nil }
$0.fileManager.fileExists = { @Sendable _ in true }
} operation: {
// setup
Current.shell.run = { @Sendable cmd, path in
$0.shell.run = { @Sendable cmd, path in
switch cmd {
case .gitCheckout(branch: "main", quiet: true) where path.hasSuffix("foo-1"):
throw SimulatedError()
Expand All @@ -183,7 +179,7 @@ final class AnalyzeErrorTests: AppTestCase {
return try Self.defaultShellRun(cmd, path)
}
}

} operation: {
// MUT
try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10))

Expand Down
Loading
Loading