Skip to content
Open
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
8 changes: 5 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
// swift-tools-version:5.10
// swift-tools-version: 6.1
import PackageDescription

let package = Package(
name: "git-kit",
// swift-subprocess declares a floor of macOS 13.
platforms: [.macOS(.v13)],
products: [
.library(name: "GitKit", targets: ["GitKit"]),
],
dependencies: [
.package(url: "https://github.com/binarybirds/shell-kit", from: "1.0.0"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
],
targets: [
.target(name: "GitKit", dependencies: [
.product(name: "ShellKit", package: "shell-kit"),
.product(name: "Subprocess", package: "swift-subprocess"),
]),
.testTarget(name: "GitKitTests", dependencies: ["GitKit"]),
]
Expand Down
67 changes: 48 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,50 @@ GitKit is a Swift wrapper around the git command line interface.

## Usage

Some basic examples:
Every command is `async`. Some basic examples:

```swift
import GitKit

try Git().run(.cmd(.config, "--global user.name"))
try await Git().run(.cmd(.config, "--global user.name"))

let git = Git(path: "~/example/")

try git.run(.cmd(.initialize))
try git.run(.cmd(.status))
try git.run(.cmd(.branch, "-a"))
try git.run(.cmd(.pull))

try git.run(.clone(url: "https://gitlab.com/binarybirds/shell-kit.git"))
try git.run(.commit(message: "some nasty bug fixed"))
try git.run(.log(1))
try git.run(.tag("1.0.0"))
try git.run(.pull(remote: "origin", branch: "master"))
try git.run(.push(remote: "origin", branch: "master"))
try git.run(.create(branch: "dev"))
try git.run(.checkout(branch: "master"))
try git.run(.merge(branch: "dev"))
try await git.run(.cmd(.initialize))
try await git.run(.cmd(.status))
try await git.run(.cmd(.branch, "-a"))
try await git.run(.cmd(.pull))

try await git.run(.clone(url: "https://github.com/armcknight/git-kit.git"))
try await git.run(.commit(message: "some nasty bug fixed"))
try await git.run(.log(numberOfCommits: 1))
try await git.run(.tag("1.0.0"))
try await git.run(.pull(remote: "origin", branch: "main"))
try await git.run(.push(remote: "origin", branch: "main"))
try await git.run(.create(branch: "dev"))
try await git.run(.checkout(branch: "main"))
try await git.run(.merge(branch: "dev"))

try await git.run(.raw("log -2"))
try await git.run(.raw("rebase -i <hash>"))
```

try git.run(.raw("log -2"))
try git.run(.raw("rebase -i <hash>"))
Commands are run through a shell (`/bin/sh` by default), so a raw command may
chain with `&&` exactly as it would at a prompt:

```swift
try await git.run("cd /some/path && git status")
```

Failures throw `Git.Error.generic(exitCode, stderr)`; output that cannot be
decoded throws `Git.Error.outputData`.

## Install

Just use the Swift Package Manager as usual:

```swift
.package(url: "https://github.com/binarybirds/git-kit", from: "1.0.0"),
.package(url: "https://github.com/armcknight/git-kit", from: "2.0.0"),
```

Don't forget to add "GitKit" to your target as a dependency:
Expand All @@ -49,6 +58,26 @@ Don't forget to add "GitKit" to your target as a dependency:

That's it.

## Migrating from 1.x

2.0.0 is a breaking release.

- **Async only.** `Git.run` is now `async throws`. The synchronous variant and
the completion-handler variant (`run(_:completion:)`) are both gone. Call
sites need `try await`.
- **No more ShellKit.** `Git` no longer subclasses `ShellKit.Shell`; commands
run on [swift-subprocess](https://github.com/swiftlang/swift-subprocess).
`binarybirds/shell-kit` was deleted from GitHub, so 1.x can no longer be
resolved on a fresh checkout at all.
- **Errors moved.** `Shell.Error` is now `Git.Error`, with the same
`outputData` and `generic(Int, String)` cases.
- **`Shell` members moved onto `Git`.** `path`, `verbose`, `type`, and `env`
are unchanged; `maxOutputSize` is new (16MB default). The macOS-only
`outputHandler` / `errorHandler` streaming hooks are gone.
- **Platform floor.** macOS 13, inherited from swift-subprocess.

The `Alias` and `Command` enums are unchanged, so command construction is
identical apart from the `await`.

## License

Expand Down
122 changes: 100 additions & 22 deletions Sources/GitKit/Git.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,29 @@
Copyright Binary Birds. All rights reserved.
*/

import ShellKit
import Foundation
import Subprocess
import System

/// a Git wrapper class
public final class Git: Shell {
public final class Git {

/// git errors
public enum Error: LocalizedError {
/// invalid shell output data error
case outputData
/// generic error, the first parameter is the error code, the second is the error message
case generic(Int, String)

public var errorDescription: String? {
switch self {
case .outputData:
return "Invalid or empty shell output."
case .generic(let code, let message):
return message + " (code: \(code))"
}
}
}

/// Git aliases to make the API more convenient
public enum Alias {
Expand Down Expand Up @@ -263,8 +282,9 @@ public final class Git: Shell {
case lsRemote = "ls-remote"
}


// MARK: - private helper methods

/**
This method helps to assemble a Git command string from an alias

Expand All @@ -273,7 +293,6 @@ public final class Git: Shell {

- Parameters:
- alias: The git alias to be executed
- args: Additional arguments for the Git alias

- Returns: The Git command
*/
Expand All @@ -292,39 +311,55 @@ public final class Git: Shell {
cmd += ["cd", quotedPath, "&&"]
}
cmd += ["git", alias.rawValue]

let command = cmd.joined(separator: " ")

if self.verbose {
print(command)
}
return command
}

// MARK: - public api

/// work directory, if peresent a directory change will occur before running any Git commands
///
/// NOTE: if the git init command is called with a non-existing path, directories
/// presented in the path string will be created recursively
public var path: String?

// prints git commands constructed from the alias before execution
public var verbose = false


/// the shell used to interpret commands, by default: /bin/sh
public var type: String

/// custom env variables exposed to the shell
public var env: [String: String]

/// maximum number of bytes collected from the shell's standard output and standard error
public var maxOutputSize: Int

/**
Initializes a new Git object

- Parameters:
- path: The path of the Swift package (work directory)
- type: The type of the shell, default: /bin/sh
- env: Additional environment variables for the shell, default: empty
- maxOutputSize: Maximum bytes to collect from stdout and stderr, default: 16MB

*/
public init(path: String? = nil, type: String = "/bin/sh", env: [String: String] = [:]) {
public init(
path: String? = nil,
type: String = "/bin/sh",
env: [String: String] = [:],
maxOutputSize: Int = 16 * 1024 * 1024
) {
self.path = path

super.init(type, env: env)
self.type = type
self.env = env
self.maxOutputSize = maxOutputSize
}

/**
Expand All @@ -334,27 +369,70 @@ public final class Git: Shell {
- alias: The git command alias to be executed

- Throws:
`ShellError.outputData` if the command execution succeeded but the output is empty,
otherwise `ShellError.generic(Int, String)` where the first parameter is the exit code,
the second is the error message
`Git.Error.outputData` if the command execution succeeded but the output could not be
decoded, otherwise `Git.Error.generic(Int, String)` where the first parameter is the
exit code, the second is the error message

- Returns: The output string of the command without trailing newlines
*/
@discardableResult
public func run(_ alias: Alias) throws -> String {
try self.run(self.rawCommand(alias))
public func run(_ alias: Alias) async throws -> String {
try await self.run(self.rawCommand(alias))
}

/**
Async version of the run function
Runs a raw command through the current shell.

- Parameters:
- alias: The git command alias to be executed
- completion: The completion block with the output and error
- command: The command to be executed

The command will be executed on a concurrent dispatch queue.
- Throws:
`Git.Error.outputData` if the command execution succeeded but the output could not be
decoded, otherwise `Git.Error.generic(Int, String)` where the first parameter is the
exit code, the second is the error message

- Returns: The output string of the command without trailing newlines
*/
public func run(_ alias: Alias, completion: @escaping ((String?, Swift.Error?) -> Void)) {
self.run(self.rawCommand(alias), completion: completion)
@discardableResult
public func run(_ command: String) async throws -> String {
var overrides: [Environment.Key: String?] = [:]
for (key, value) in self.env {
guard let key = Environment.Key(rawValue: key) else { continue }
overrides[key] = value
}
let environment: Environment = overrides.isEmpty
? .inherit
: .inherit.updating(overrides)

let result = try await Subprocess.run(
.path(FilePath(self.type)),
arguments: Arguments(["-c", command]),
environment: environment,
output: .string(limit: self.maxOutputSize),
error: .string(limit: self.maxOutputSize)
)

guard result.terminationStatus.isSuccess else {
let code: Int
switch result.terminationStatus {
case .exited(let status): code = Int(status)
case .signaled(let signal): code = Int(signal)
}
let message = result.standardError?
.trimmingCharacters(in: .newlines)
.nilIfEmpty ?? "Unknown error"
throw Error.generic(code, message)
}

guard let output = result.standardOutput else {
throw Error.outputData
}
return output.trimmingCharacters(in: .newlines)
}
}

private extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
Loading
Loading