From b11d9d41ea183e10826e6861ad26080da685f801 Mon Sep 17 00:00:00 2001 From: maniramezan Date: Sat, 11 Jul 2026 15:59:30 -0400 Subject: [PATCH 1/2] Fix Git structured output parsing --- .claude/skills/swiftyshell.md | 1 + Sources/SwiftyShell/Core/ShellError.swift | 9 + Sources/SwiftyShell/Git/Git.swift | 5 +- Sources/SwiftyShell/Git/GitCommands.swift | 32 ++-- Sources/SwiftyShell/Git/GitParsers.swift | 164 ++++++++++++------ Sources/SwiftyShell/Git/GitSubmodule.swift | 4 +- Sources/SwiftyShell/SwiftyShell.docc/Git.md | 7 + .../SwiftyShell.docc/ShellError.md | 2 + .../Core/ShellErrorTests.swift | 5 + .../Git/GitCommandFamilyTests.swift | 121 ++++++++++++- .../SwiftyShellTests/Git/GitParserTests.swift | 85 +++++++-- 11 files changed, 352 insertions(+), 83 deletions(-) diff --git a/.claude/skills/swiftyshell.md b/.claude/skills/swiftyshell.md index d1d9cfb..17a96a2 100644 --- a/.claude/skills/swiftyshell.md +++ b/.claude/skills/swiftyshell.md @@ -231,6 +231,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) diff --git a/Sources/SwiftyShell/Core/ShellError.swift b/Sources/SwiftyShell/Core/ShellError.swift index 1ca22e5..afc2692 100644 --- a/Sources/SwiftyShell/Core/ShellError.swift +++ b/Sources/SwiftyShell/Core/ShellError.swift @@ -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: @@ -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, _): diff --git a/Sources/SwiftyShell/Git/Git.swift b/Sources/SwiftyShell/Git/Git.swift index 6ea90df..72aef21 100644 --- a/Sources/SwiftyShell/Git/Git.swift +++ b/Sources/SwiftyShell/Git/Git.swift @@ -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) } ) } diff --git a/Sources/SwiftyShell/Git/GitCommands.swift b/Sources/SwiftyShell/Git/GitCommands.swift index d1ca005..045e833 100644 --- a/Sources/SwiftyShell/Git/GitCommands.swift +++ b/Sources/SwiftyShell/Git/GitCommands.swift @@ -293,13 +293,14 @@ public struct GitBranch: RunnableCommandFamily { /// - Returns: A single-use ``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) } } @@ -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") } @@ -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() @@ -881,10 +889,10 @@ public struct GitDiff: RunnableCommandFamily { /// - Returns: A single-use ``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) } } @@ -1042,10 +1050,12 @@ public struct GitLog: RunnableCommandFamily { /// - Returns: A single-use ``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) } } diff --git a/Sources/SwiftyShell/Git/GitParsers.swift b/Sources/SwiftyShell/Git/GitParsers.swift index 5e85868..3450ae3 100644 --- a/Sources/SwiftyShell/Git/GitParsers.swift +++ b/Sources/SwiftyShell/Git/GitParsers.swift @@ -2,6 +2,20 @@ import Foundation enum GitParsers { + static func parse( + _ 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? @@ -9,31 +23,45 @@ enum GitParsers { 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( @@ -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, @@ -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], @@ -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[.. 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) } } diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Git.md b/Sources/SwiftyShell/SwiftyShell.docc/Git.md index 3470a42..39553ca 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/Git.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/Git.md @@ -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) diff --git a/Sources/SwiftyShell/SwiftyShell.docc/ShellError.md b/Sources/SwiftyShell/SwiftyShell.docc/ShellError.md index bda8b73..4656cf1 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/ShellError.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/ShellError.md @@ -8,6 +8,8 @@ Every failure inside SwiftyShell — typed command families, raw ``Command`` calls, ``Pipeline`` stages, and ``Workflow`` gates alike — surfaces as a ``ShellError``. Match on a specific case rather than testing exit codes or parsing stderr strings; the cases are stable, the messages inside them are not. +Structured command workflows report successful commands with malformed output as +``parsingError(command:reason:)`` rather than silently returning an empty result. The most common shape is a `do/catch` that handles the specific failures you care about and lets the rest propagate: diff --git a/Tests/SwiftyShellTests/Core/ShellErrorTests.swift b/Tests/SwiftyShellTests/Core/ShellErrorTests.swift index 01e7130..bc3965f 100644 --- a/Tests/SwiftyShellTests/Core/ShellErrorTests.swift +++ b/Tests/SwiftyShellTests/Core/ShellErrorTests.swift @@ -37,6 +37,11 @@ struct ShellErrorTests { #expect(error.errorDescription == "Failed to decode stderr output for 'cat' as UTF-8") } + @Test func parsingErrorIncludesReason() { + let error = ShellError.parsingError(command: "git status", reason: "malformed record") + #expect(error.errorDescription == "Failed to parse output for 'git status': malformed record") + } + @Test func outputLimitExceededDescription() { let error = ShellError.outputLimitExceeded(command: "find .", limit: 1024, partialOutput: emptyOutput) #expect(error.errorDescription == "'find .' exceeded the output limit of 1024 bytes") diff --git a/Tests/SwiftyShellTests/Git/GitCommandFamilyTests.swift b/Tests/SwiftyShellTests/Git/GitCommandFamilyTests.swift index 9342fdc..3de4b0d 100644 --- a/Tests/SwiftyShellTests/Git/GitCommandFamilyTests.swift +++ b/Tests/SwiftyShellTests/Git/GitCommandFamilyTests.swift @@ -598,6 +598,27 @@ struct GitCommandFamilyTests { #expect(entries.contains { $0.name == "main" && $0.isCurrent }) } + @Test func typedBranchEntriesPreserveAllAndForceCapture() async throws { + let recorder = GitCommandRecorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "*\tmain\torigin/main\n", stderr: "", exitCode: 0) + } + ) + + _ = try await Git(context: context) + .branch() + .all() + .stdout(.discard) + .entries() + .run() + + let command = try #require(await recorder.snapshot().first) + #expect(command.arguments == ["branch", "--format=%(HEAD)\t%(refname:short)\t%(upstream:short)", "--all"]) + #expect(command.stdoutDestination == .capture) + } + @Test func parsesTypedLogEntriesFromRepository() async throws { let repoURL = try makeTemporaryDirectoryForGitCommandTests() defer { try? FileManager.default.removeItem(at: repoURL) } @@ -627,6 +648,24 @@ struct GitCommandFamilyTests { #expect(entries[0].subject == "initial commit") } + @Test func typedLogEntriesForceCapture() async throws { + let recorder = GitCommandRecorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput( + stdout: "hash\u{1F}short\u{1F}name\u{1F}email\u{1F}subject\n", + stderr: "", + exitCode: 0 + ) + } + ) + + _ = try await Git(context: context).log().stdout(.file(path: "/tmp/log", append: false)).entries().run() + + #expect(try #require(await recorder.snapshot().first).stdoutDestination == .capture) + } + @Test func parsesTypedDiffFileChangesFromRepository() async throws { let repoURL = try makeTemporaryDirectoryForGitCommandTests() defer { try? FileManager.default.removeItem(at: repoURL) } @@ -655,6 +694,52 @@ struct GitCommandFamilyTests { #expect(changes.contains { $0.path == "README.md" && $0.kind == .modified }) } + @Test func typedDiffPreservesUnusualRenamePaths() async throws { + let repoURL = try makeTemporaryDirectoryForGitCommandTests() + defer { try? FileManager.default.removeItem(at: repoURL) } + let context = ShellContext() + try await initializeRepository(at: repoURL, context: context) + + let oldPath = "old name\twith tab.txt" + let newPath = "new name\nwith newline.txt" + try "content".write(to: repoURL.appendingPathComponent(oldPath), atomically: true, encoding: .utf8) + _ = try await Command("git", arguments: "add", "--", oldPath).workingDirectory(repoURL.path).run(in: context) + _ = try await Command("git", arguments: "commit", "-m", "add unusual path") + .workingDirectory(repoURL.path) + .run(in: context) + _ = try await Command("git", arguments: "mv", "--", oldPath, newPath).workingDirectory(repoURL.path).run( + in: context + ) + + let changes = try await Git(context: context) + .workingDirectory(repoURL.path) + .diff() + .staged() + .stdout(.discard) + .fileChanges() + .run() + + #expect( + changes == [GitDiffFileChange(kind: .renamed, path: newPath, originalPath: oldPath, statusCode: "R100")] + ) + } + + @Test func typedDiffPlacesNULTerminationBeforePathspec() async throws { + let recorder = GitCommandRecorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "M\0path\0", stderr: "", exitCode: 0) + } + ) + + _ = try await Git(context: context).diff().path("path").fileChanges().run() + + #expect( + try #require(await recorder.snapshot().first).arguments == ["diff", "--name-status", "-z", "--", "path"] + ) + } + @Test func parsesTypedSubmoduleStatusEntriesFromRepository() async throws { let parentURL = try makeTemporaryDirectoryForGitCommandTests() let childURL = try makeTemporaryDirectoryForGitCommandTests() @@ -664,7 +749,7 @@ struct GitCommandFamilyTests { let context = ShellContext() try await initializeRepository(at: childURL, context: context) try await initializeRepository(at: parentURL, context: context) - _ = try await Command("git", arguments: "submodule", "add", childURL.path, "Vendor/Child") + _ = try await Command("git", arguments: "submodule", "add", childURL.path, "Vendor/Child With Spaces") .env("GIT_ALLOW_PROTOCOL", "file") .workingDirectory(parentURL.path) .run(in: context) @@ -677,7 +762,7 @@ struct GitCommandFamilyTests { #expect(entries.count == 1) #expect(entries[0].state == .current) - #expect(entries[0].path == "Vendor/Child") + #expect(entries[0].path == "Vendor/Child With Spaces") #expect(!entries[0].commitHash.isEmpty) } @@ -708,6 +793,7 @@ struct GitCommandFamilyTests { .recursive() .cached() .path("Vendor/Ready") + .stdout(.discard) .statusEntries() .run() @@ -733,6 +819,37 @@ struct GitCommandFamilyTests { ] ] ) + #expect(try #require(await recorder.snapshot().first).stdoutDestination == .capture) + } + + @Test func malformedTypedOutputThrowsShellError() async throws { + let context = ShellContext( + executor: MockExecutor { _, _ in + ShellOutput(stdout: "malformed", stderr: "", exitCode: 0) + } + ) + + do { + _ = try await Git(context: context).branch().entries().run() + Issue.record("Expected parsingError") + } catch let ShellError.parsingError(command, reason) { + #expect(command.contains("git branch")) + #expect(reason.contains("malformed record")) + } catch { + Issue.record("Expected parsingError, got \(error)") + } + } +} + +private actor GitCommandRecorder { + private var commands: [Command] = [] + + func record(_ command: Command) { + commands.append(command) + } + + func snapshot() -> [Command] { + commands } } diff --git a/Tests/SwiftyShellTests/Git/GitParserTests.swift b/Tests/SwiftyShellTests/Git/GitParserTests.swift index a6f8168..41069f2 100644 --- a/Tests/SwiftyShellTests/Git/GitParserTests.swift +++ b/Tests/SwiftyShellTests/Git/GitParserTests.swift @@ -125,13 +125,13 @@ struct GitParserTests { #expect(status.hasUntrackedFiles) } - @Test func parsesBranchEntries() { + @Test func parsesBranchEntries() throws { let output = """ * main origin/main feature/demo origin/feature/demo remotes/origin/main """ - let entries = GitParsers.parseBranchEntries(output) + let entries = try GitParsers.parseBranchEntries(output) #expect( entries == [ @@ -142,12 +142,12 @@ struct GitParserTests { ) } - @Test func parsesLogEntries() { + @Test func parsesLogEntries() throws { let output = """ abcdef1234567890\u{1F}abcdef1\u{1F}Test User\u{1F}test@example.com\u{1F}Initial commit fedcba0987654321\u{1F}fedcba0\u{1F}Other User\u{1F}other@example.com\u{1F}Follow-up change """ - let entries = GitParsers.parseLogEntries(output) + let entries = try GitParsers.parseLogEntries(output) #expect( entries == [ @@ -169,43 +169,51 @@ struct GitParserTests { ) } - @Test func parsesDiffFileChanges() { - let output = """ - M\tREADME.md - A\tSources/NewFile.swift - R100\tOld.swift\tNew.swift - """ - let changes = GitParsers.parseDiffFileChanges(output) + @Test func parsesNULTerminatedDiffFileChanges() throws { + let output = + "M\0README.md\0A\0Sources/New\tFile.swift\0R100\0Old\nFile.swift\0New.swift\0C75\0Copy Source\0Copy Target\0" + let changes = try GitParsers.parseDiffFileChanges(output) #expect( changes == [ GitDiffFileChange(kind: .modified, path: "README.md", originalPath: nil, statusCode: "M"), GitDiffFileChange( kind: .added, - path: "Sources/NewFile.swift", + path: "Sources/New\tFile.swift", originalPath: nil, statusCode: "A" ), - GitDiffFileChange(kind: .renamed, path: "New.swift", originalPath: "Old.swift", statusCode: "R100"), + GitDiffFileChange( + kind: .renamed, + path: "New.swift", + originalPath: "Old\nFile.swift", + statusCode: "R100" + ), + GitDiffFileChange( + kind: .copied, + path: "Copy Target", + originalPath: "Copy Source", + statusCode: "C75" + ), ] ) } - @Test func parsesSubmoduleStatusEntries() { + @Test func parsesSubmoduleStatusEntries() throws { let output = """ - abcdef1234567890abcdef1234567890abcdef12 Vendor/Ready (heads/main) + abcdef1234567890abcdef1234567890abcdef12 Vendor/Ready With Spaces (heads/main) -fedcba0987654321fedcba0987654321fedcba09 Vendor/Missing +1234567890abcdef1234567890abcdef12345678 Vendor/Changed (v1.2.3-4-g1234567) U0987654321fedcba0987654321fedcba09876543 Vendor/Conflicted """ - let entries = GitParsers.parseSubmoduleStatusEntries(output) + let entries = try GitParsers.parseSubmoduleStatusEntries(output) #expect( entries == [ GitSubmoduleStatusEntry( state: .current, commitHash: "abcdef1234567890abcdef1234567890abcdef12", - path: "Vendor/Ready", + path: "Vendor/Ready With Spaces", description: "(heads/main)" ), GitSubmoduleStatusEntry( @@ -229,5 +237,48 @@ struct GitParserTests { ] ) } + + @Test(arguments: [ + "unexpected", + "# branch.oid abc123", + "# branch.head main\n1 X N... file", + "# branch.head ", + ]) func rejectsMalformedStatus(output: String) { + #expect(throws: GitParsers.ParseError.self) { + try GitParsers.parseStatus(output) + } + } + + @Test(arguments: ["*\tmain", "x\tmain\torigin/main", "\t\torigin/main"]) + func rejectsMalformedBranchEntry(output: String) { + #expect(throws: GitParsers.ParseError.self) { + try GitParsers.parseBranchEntries(output) + } + } + + @Test(arguments: ["hash\u{1F}short", "\u{1F}short\u{1F}name\u{1F}email\u{1F}subject"]) + func rejectsMalformedLogEntry(output: String) { + #expect(throws: GitParsers.ParseError.self) { + try GitParsers.parseLogEntries(output) + } + } + + @Test(arguments: ["M\0path", "M\0", "R100\0old\0", "\0"]) + func rejectsMalformedDiffEntry(output: String) { + #expect(throws: GitParsers.ParseError.self) { + try GitParsers.parseDiffFileChanges(output) + } + } + + @Test(arguments: ["+hash-only", " hash ", ""]) + func rejectsMalformedSubmoduleEntry(output: String) throws { + if output.isEmpty { + #expect(try GitParsers.parseSubmoduleStatusEntries(output).isEmpty) + } else { + #expect(throws: GitParsers.ParseError.self) { + try GitParsers.parseSubmoduleStatusEntries(output) + } + } + } } #endif From d7d971e2eb9917db0b6df743d8ff4bf58232f2e1 Mon Sep 17 00:00:00 2001 From: maniramezan Date: Sun, 12 Jul 2026 02:58:16 -0400 Subject: [PATCH 2/2] Stabilize pipeline cancellation coverage test --- Tests/SwiftyShellTests/Pipelines/PipelineTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift b/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift index 3961a69..b9d9624 100644 --- a/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift +++ b/Tests/SwiftyShellTests/Pipelines/PipelineTests.swift @@ -133,20 +133,23 @@ struct PipelineTests { @Test func pipelineCancellationPreservesPartialOutput() async throws { let marker = "/tmp/swiftyshell-pipeline-cancel-\(UUID().uuidString)" + let outputMarker = "\(marker)-output" defer { try? FileManager.default.removeItem(atPath: marker) } + defer { try? FileManager.default.removeItem(atPath: outputMarker) } let task = Task { try await Command("/bin/sh", arguments: "-c", "printf 'start'; exec sleep 30") .pipe( to: Command( "/bin/sh", arguments: "-c", - "dd bs=5 count=1 2>/dev/null; touch '\(marker)'; exec sleep 30" + "chunk=$(dd bs=5 count=1 2>/dev/null); printf '%s' \"$chunk\"; touch '\(outputMarker)'; touch '\(marker)'; exec sleep 30" ) ) .run(in: ShellContext()) } try await waitForFile(at: marker) + try await waitForFile(at: outputMarker) task.cancel() do {