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
1 change: 1 addition & 0 deletions .claude/skills/swiftyshell.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ public enum ShellError: Error, LocalizedError {
case exitFailure(command: String, output: ShellOutput)
case timeout(command: String, duration: TimeInterval, partialOutput: ShellOutput)
case decodingError(command: String, stream: StreamKind)
case parsingError(command: String, reason: String)
case outputLimitExceeded(command: String, limit: Int, partialOutput: ShellOutput)
case canceled(command: String, partialOutput: ShellOutput)
case spawnError(command: String, reason: String)
Expand Down
9 changes: 9 additions & 0 deletions Sources/SwiftyShell/Core/ShellError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ public enum ShellError: Error, LocalizedError, Sendable {
/// ``StreamKind/stderr``).
case decodingError(command: String, stream: StreamKind)

/// A command succeeded, but its structured output did not match the expected format.
///
/// - Parameters:
/// - command: The shell-quoted display string of the command that produced malformed output.
/// - reason: A human-readable description of the malformed record.
case parsingError(command: String, reason: String)

/// Captured output exceeded the configured limit and the process was terminated.
///
/// - Parameters:
Expand Down Expand Up @@ -106,6 +113,8 @@ public enum ShellError: Error, LocalizedError, Sendable {
return "'\(command)' timed out after \(duration) seconds"
case let .decodingError(command, stream):
return "Failed to decode \(stream) output for '\(command)' as UTF-8"
case let .parsingError(command, reason):
return "Failed to parse output for '\(command)': \(reason)"
case let .outputLimitExceeded(command, limit, _):
return "'\(command)' exceeded the output limit of \(limit) bytes"
case let .canceled(command, _):
Expand Down
5 changes: 3 additions & 2 deletions Sources/SwiftyShell/Git/Git.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ public struct Git: ToolConfigurableCommandFamily {
GitStatusWorkflow(
git: self,
workflow: Workflow {
let output = try await makeCommand("status", "--porcelain=v2", "--branch").run(in: context)
return try GitParsers.parseStatus(output.stdout)
let command = makeCommand("status", "--porcelain=v2", "--branch").stdout(.capture)
let output = try await command.run(in: context)
return try GitParsers.parse(output.stdout, from: command, using: GitParsers.parseStatus)
}
)
}
Expand Down
32 changes: 21 additions & 11 deletions Sources/SwiftyShell/Git/GitCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,14 @@ public struct GitBranch: RunnableCommandFamily {
/// - Returns: A ``Workflow`` producing parsed ``GitBranchEntry`` values.
public func entries() -> Workflow<[GitBranchEntry]> {
let git = state.git
let command = git.makeCommand(
"branch",
"--format=%(HEAD)\t%(refname:short)\t%(upstream:short)"
)
var arguments = ["branch", "--format=%(HEAD)\t%(refname:short)\t%(upstream:short)"]
if state.includesAllBranches {
arguments.append("--all")
}
let command = git.makeCommand(arguments).stdout(.capture)
return Workflow {
let output = try await command.run(in: git.context)
return GitParsers.parseBranchEntries(output.stdout)
return try GitParsers.parse(output.stdout, from: command, using: GitParsers.parseBranchEntries)
}
}

Expand Down Expand Up @@ -848,8 +849,15 @@ public struct GitDiff: RunnableCommandFamily {
///
/// - Returns: A ``Command`` ready for execution or pipeline composition.
public func command() -> Command {
buildCommand(nullTerminated: false)
}

private func buildCommand(nullTerminated: Bool) -> Command {
var arguments = ["diff"]
arguments.append(contentsOf: state.format.arguments)
if nullTerminated {
arguments.append("-z")
}
if state.staged {
arguments.append("--staged")
}
Expand All @@ -866,10 +874,10 @@ public struct GitDiff: RunnableCommandFamily {
.stderr(state.stderrDestination)
}

/// Runs `git diff --name-status` and parses the output into typed file changes.
/// Runs `git diff --name-status -z` and parses the output into typed file changes.
///
/// Forces ``GitDiffFormat/nameStatus`` regardless of any prior ``format(_:)`` call so the
/// parser can read git's status code plus path columns reliably.
/// parser can preserve arbitrary valid file names, including tabs and newlines.
///
/// ```swift
/// let changes = try await git.diff().staged().fileChanges().run()
Expand All @@ -881,10 +889,10 @@ public struct GitDiff: RunnableCommandFamily {
/// - Returns: A ``Workflow`` producing parsed ``GitDiffFileChange`` values.
public func fileChanges() -> Workflow<[GitDiffFileChange]> {
let git = state.git
let command = self.format(.nameStatus).command()
let command = self.format(.nameStatus).settingStdoutDestination(.capture).buildCommand(nullTerminated: true)
return Workflow {
let output = try await command.run(in: git.context)
return GitParsers.parseDiffFileChanges(output.stdout)
return try GitParsers.parse(output.stdout, from: command, using: GitParsers.parseDiffFileChanges)
}
}

Expand Down Expand Up @@ -1042,10 +1050,12 @@ public struct GitLog: RunnableCommandFamily {
/// - Returns: A ``Workflow`` producing parsed ``GitLogEntry`` values.
public func entries() -> Workflow<[GitLogEntry]> {
let git = state.git
let command = self.format(.pretty("format:%H%x1f%h%x1f%an%x1f%ae%x1f%s")).command()
let command = self.format(.pretty("format:%H%x1f%h%x1f%an%x1f%ae%x1f%s"))
.settingStdoutDestination(.capture)
.command()
return Workflow {
let output = try await command.run(in: git.context)
return GitParsers.parseLogEntries(output.stdout)
return try GitParsers.parse(output.stdout, from: command, using: GitParsers.parseLogEntries)
}
}

Expand Down
164 changes: 115 additions & 49 deletions Sources/SwiftyShell/Git/GitParsers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,66 @@
import Foundation

enum GitParsers {
static func parse<Value>(
_ output: String,
from command: Command,
using parser: (String) throws -> Value
) throws -> Value {
do {
return try parser(output)
} catch let error as ShellError {
throw error
} catch {
throw ShellError.parsingError(command: command.displayString(), reason: String(describing: error))
}
}

static func parseStatus(_ output: String) throws -> GitStatus {
var branch: String?
var upstream: String?
var hasStagedChanges = false
var hasUnstagedChanges = false
var hasUntrackedFiles = false

var foundBranchHead = false
for line in output.split(whereSeparator: \.isNewline).map(String.init) {
if let head = line.stripPrefix("# branch.head ") {
guard !head.isEmpty else { throw ParseError.malformedRecord(line) }
branch = head == "(detached)" ? nil : head
foundBranchHead = true
continue
}
if let trackedUpstream = line.stripPrefix("# branch.upstream ") {
guard !trackedUpstream.isEmpty else { throw ParseError.malformedRecord(line) }
upstream = trackedUpstream
continue
}
if line.hasPrefix("# branch.oid ") || line.hasPrefix("# branch.ab ") {
continue
}
if line.hasPrefix("? ") {
guard line.count > 2 else { throw ParseError.malformedRecord(line) }
hasUntrackedFiles = true
continue
}
if line.hasPrefix("! ") {
guard line.count > 2 else { throw ParseError.malformedRecord(line) }
continue
}
if line.hasPrefix("1 ") || line.hasPrefix("2 ") || line.hasPrefix("u ") {
let parts = line.split(separator: " ")
if parts.count > 1 {
let xy = Array(parts[1])
if xy.count >= 2 {
hasStagedChanges = hasStagedChanges || xy[0] != "."
hasUnstagedChanges = hasUnstagedChanges || xy[1] != "."
}
}
guard parts.count > 1 else { throw ParseError.malformedRecord(line) }
let xy = Array(parts[1])
guard xy.count == 2 else { throw ParseError.malformedRecord(line) }
hasStagedChanges = hasStagedChanges || xy[0] != "."
hasUnstagedChanges = hasUnstagedChanges || xy[1] != "."
continue
}
throw ParseError.malformedRecord(line)
}

guard foundBranchHead else { throw ParseError.missingRecord("# branch.head") }

let state: GitWorkingTreeState =
(hasStagedChanges || hasUnstagedChanges || hasUntrackedFiles) ? .dirty : .noChanges
return GitStatus(
Expand All @@ -46,17 +74,19 @@ enum GitParsers {
)
}

static func parseBranchEntries(_ output: String) -> [GitBranchEntry] {
output
static func parseBranchEntries(_ output: String) throws -> [GitBranchEntry] {
try output
.split(whereSeparator: \.isNewline)
.compactMap { rawLine -> GitBranchEntry? in
.map { rawLine -> GitBranchEntry in
let parts = rawLine.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
guard parts.count == 3 else { return nil }
guard parts.count == 3 else { throw ParseError.malformedRecord(String(rawLine)) }

let headMarker = parts[0].trimmingCharacters(in: .whitespaces)
let name = parts[1].trimmingCharacters(in: .whitespaces)
let upstream = parts[2].trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return nil }
guard (headMarker.isEmpty || headMarker == "*"), !name.isEmpty else {
throw ParseError.malformedRecord(String(rawLine))
}

return GitBranchEntry(
name: name,
Expand All @@ -66,12 +96,14 @@ enum GitParsers {
}
}

static func parseLogEntries(_ output: String) -> [GitLogEntry] {
output
static func parseLogEntries(_ output: String) throws -> [GitLogEntry] {
try output
.split(whereSeparator: \.isNewline)
.compactMap { rawLine -> GitLogEntry? in
.map { rawLine -> GitLogEntry in
let parts = rawLine.split(separator: "\u{1F}", omittingEmptySubsequences: false).map(String.init)
guard parts.count == 5 else { return nil }
guard parts.count == 5, !parts[0].isEmpty, !parts[1].isEmpty else {
throw ParseError.malformedRecord(String(rawLine))
}
return GitLogEntry(
commitHash: parts[0],
abbreviatedCommitHash: parts[1],
Expand All @@ -82,48 +114,65 @@ enum GitParsers {
}
}

static func parseDiffFileChanges(_ output: String) -> [GitDiffFileChange] {
output
.split(whereSeparator: \.isNewline)
.compactMap { rawLine -> GitDiffFileChange? in
let components = rawLine.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
guard let statusCode = components.first, components.count >= 2 else { return nil }

let kind = parseDiffChangeKind(statusCode)
if statusCode.hasPrefix("R") || statusCode.hasPrefix("C") {
guard components.count >= 3 else { return nil }
return GitDiffFileChange(
kind: kind,
path: components[2],
originalPath: components[1],
statusCode: statusCode
)
}

return GitDiffFileChange(
kind: kind,
path: components[1],
originalPath: nil,
static func parseDiffFileChanges(_ output: String) throws -> [GitDiffFileChange] {
guard output.isEmpty || output.last == "\0" else { throw ParseError.missingTerminator }
let fields = output.split(separator: "\0", omittingEmptySubsequences: false).dropLast().map(String.init)
var index = 0
var changes: [GitDiffFileChange] = []
while index < fields.count {
let statusCode = fields[index]
guard !statusCode.isEmpty else { throw ParseError.malformedRecord(statusCode) }
let pathCount = statusCode.hasPrefix("R") || statusCode.hasPrefix("C") ? 2 : 1
guard index + pathCount < fields.count else { throw ParseError.malformedRecord(statusCode) }
let paths = fields[(index + 1)...(index + pathCount)]
guard paths.allSatisfy({ !$0.isEmpty }) else { throw ParseError.malformedRecord(statusCode) }
changes.append(
GitDiffFileChange(
kind: parseDiffChangeKind(statusCode),
path: paths.last ?? "",
originalPath: pathCount == 2 ? paths.first : nil,
statusCode: statusCode
)
}
)
index += pathCount + 1
}
return changes
}

static func parseSubmoduleStatusEntries(_ output: String) -> [GitSubmoduleStatusEntry] {
output
static func parseSubmoduleStatusEntries(_ output: String) throws -> [GitSubmoduleStatusEntry] {
try output
.split(whereSeparator: \.isNewline)
.compactMap { rawLine -> GitSubmoduleStatusEntry? in
guard let statePrefix = rawLine.first else { return nil }
.map { rawLine -> GitSubmoduleStatusEntry in
guard let statePrefix = rawLine.first else { throw ParseError.malformedRecord(String(rawLine)) }
let state = parseSubmoduleStatusState(String(statePrefix))
let body = rawLine.dropFirst().trimmingCharacters(in: .whitespaces)
let parts = body.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false).map(String.init)
guard parts.count >= 2 else { return nil }
let body = rawLine.dropFirst()
guard let separator = body.firstIndex(of: " ") else {
throw ParseError.malformedRecord(String(rawLine))
}
let commitHash = String(body[..<separator])
let pathAndDescription = String(body[body.index(after: separator)...])
guard !commitHash.isEmpty, !pathAndDescription.isEmpty else {
throw ParseError.malformedRecord(String(rawLine))
}
let descriptionStart = pathAndDescription.range(of: " (", options: .backwards)
let hasDescription = descriptionStart != nil && pathAndDescription.hasSuffix(")")
let path =
hasDescription
? String(pathAndDescription[..<(descriptionStart?.lowerBound ?? pathAndDescription.endIndex)])
: pathAndDescription
let description =
hasDescription
? String(
pathAndDescription[(descriptionStart?.upperBound ?? pathAndDescription.endIndex)...].dropLast()
)
: nil
guard !path.isEmpty else { throw ParseError.malformedRecord(String(rawLine)) }

return GitSubmoduleStatusEntry(
state: state,
commitHash: parts[0],
path: parts[1],
description: parts.count == 3 && !parts[2].isEmpty ? parts[2] : nil
commitHash: commitHash,
path: path,
description: description.map { "(\($0))" }
)
}
}
Expand Down Expand Up @@ -167,6 +216,23 @@ enum GitParsers {
return .unknown(prefix)
}
}

enum ParseError: Error, Equatable, CustomStringConvertible {
case malformedRecord(String)
case missingRecord(String)
case missingTerminator

var description: String {
switch self {
case let .malformedRecord(record):
return "malformed record \(record.debugDescription)"
case let .missingRecord(record):
return "missing required record \(record.debugDescription)"
case .missingTerminator:
return "missing NUL record terminator"
}
}
}
}

private extension String {
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftyShell/Git/GitSubmodule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -627,10 +627,10 @@ public struct GitSubmodule: RunnableCommandFamily {
/// - Returns: A ``Workflow`` producing parsed ``GitSubmoduleStatusEntry`` values.
public func statusEntries() -> Workflow<[GitSubmoduleStatusEntry]> {
let git = state.git
let command = self.status().command()
let command = self.status().settingStdoutDestination(.capture).command()
return Workflow {
let output = try await command.run(in: git.context)
return GitParsers.parseSubmoduleStatusEntries(output.stdout)
return try GitParsers.parse(output.stdout, from: command, using: GitParsers.parseSubmoduleStatusEntries)
}
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/SwiftyShell/SwiftyShell.docc/Git.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ Use ``GitSubmodule/recursive(_:)`` when you also want nested submodules. The
result is an array of entries, one per submodule path, that you can branch on
without parsing stdout yourself.

Typed git workflows always capture their parser input, even if the fluent command
was previously configured to redirect stdout. Structured diff results use git's
NUL-delimited format so spaces, tabs, newlines, and rename or copy paths are
preserved exactly. Malformed structured output throws
``ShellError/parsingError(command:reason:)`` rather than being treated as an
empty result or clean repository.

```swift
let entries = try await Git(context: context)
.workingDirectory(repoPath)
Expand Down
Loading
Loading