From 9d683f5f473910e54558751d0ddbf9ba4c993f75 Mon Sep 17 00:00:00 2001 From: Gage Halverson Date: Fri, 6 Feb 2026 23:12:08 -0700 Subject: [PATCH 1/5] Refactor tests to use scoped temp directory helpers --- Package.swift | 4 + Sources/sprout/Commands/Prune.swift | 3 - Sources/sprout/Models/InputSource.swift | 2 +- Sources/sprout/Services/GitHubClient.swift | 5 +- Sources/sprout/Services/GitService.swift | 16 +++- Sources/sprout/Services/JiraClient.swift | 5 +- .../ConfigLoaderServiceTests.swift | 29 +++++++ Tests/sproutTests/GitServiceTests.swift | 40 +++++++++ Tests/sproutTests/HooksServiceTests.swift | 21 +++++ Tests/sproutTests/InputDetectorTests.swift | 17 ++++ Tests/sproutTests/InterpolationTests.swift | 12 +++ Tests/sproutTests/PromptComposerTests.swift | 26 ++++++ Tests/sproutTests/ScriptRunnerTests.swift | 15 ++++ Tests/sproutTests/SlugifyTests.swift | 18 ++++ Tests/sproutTests/TestSupport.swift | 83 +++++++++++++++++++ Tests/sproutTests/TestTags.swift | 6 ++ 16 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 Tests/sproutTests/ConfigLoaderServiceTests.swift create mode 100644 Tests/sproutTests/GitServiceTests.swift create mode 100644 Tests/sproutTests/HooksServiceTests.swift create mode 100644 Tests/sproutTests/InputDetectorTests.swift create mode 100644 Tests/sproutTests/InterpolationTests.swift create mode 100644 Tests/sproutTests/PromptComposerTests.swift create mode 100644 Tests/sproutTests/ScriptRunnerTests.swift create mode 100644 Tests/sproutTests/SlugifyTests.swift create mode 100644 Tests/sproutTests/TestSupport.swift create mode 100644 Tests/sproutTests/TestTags.swift diff --git a/Package.swift b/Package.swift index dac325d..911f46b 100644 --- a/Package.swift +++ b/Package.swift @@ -22,5 +22,9 @@ let package = Package( .product(name: "FuzzyTUI", package: "tui-fuzzy-finder"), ] ), + .testTarget( + name: "sproutTests", + dependencies: ["sprout"] + ), ] ) diff --git a/Sources/sprout/Commands/Prune.swift b/Sources/sprout/Commands/Prune.swift index d2a6daf..aa5e379 100644 --- a/Sources/sprout/Commands/Prune.swift +++ b/Sources/sprout/Commands/Prune.swift @@ -1,5 +1,4 @@ import ArgumentParser -import Darwin import Foundation import FuzzyTUI @@ -133,7 +132,6 @@ struct Prune: AsyncParsableCommand { print(" - \(wt.branch)") } print("\nConfirm? [y/N]: ", terminator: "") - fflush(stdout) guard let confirm = readLine()?.lowercased(), confirm == "y" || confirm == "yes" else { print("Cancelled.") return @@ -152,7 +150,6 @@ struct Prune: AsyncParsableCommand { } } else { print("Removing \(wt.branch)...", terminator: " ") - fflush(stdout) do { // Capture worktree path before removal for hook let worktreePath = wt.path diff --git a/Sources/sprout/Models/InputSource.swift b/Sources/sprout/Models/InputSource.swift index c53238f..1983e92 100644 --- a/Sources/sprout/Models/InputSource.swift +++ b/Sources/sprout/Models/InputSource.swift @@ -1,7 +1,7 @@ import Foundation /// Represents the detected source type for the input -enum InputSource: CustomStringConvertible { +enum InputSource: CustomStringConvertible, Equatable { /// Jira ticket (e.g., "IOS-1234") case jira(String) diff --git a/Sources/sprout/Services/GitHubClient.swift b/Sources/sprout/Services/GitHubClient.swift index 87b6198..172aaa0 100644 --- a/Sources/sprout/Services/GitHubClient.swift +++ b/Sources/sprout/Services/GitHubClient.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Client for fetching issues from GitHub API struct GitHubClient { @@ -95,7 +98,7 @@ struct GitHubClient { case 404: throw SourceError.ticketNotFound(errorId) default: - throw SourceError.networkError(URLError(.init(rawValue: httpResponse.statusCode))) + throw SourceError.networkError(URLError(.badServerResponse)) } } } diff --git a/Sources/sprout/Services/GitService.swift b/Sources/sprout/Services/GitService.swift index a9a1e7b..c251893 100644 --- a/Sources/sprout/Services/GitService.swift +++ b/Sources/sprout/Services/GitService.swift @@ -2,11 +2,18 @@ import Foundation /// Git operations using Foundation Process struct GitService { + let workingDirectoryURL: URL? + + init(workingDirectoryURL: URL? = nil) { + self.workingDirectoryURL = workingDirectoryURL + } + /// Run a git command and return stdout private func run(_ arguments: [String]) throws -> String { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = arguments + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + process.currentDirectoryURL = workingDirectoryURL let stdout = Pipe() process.standardOutput = stdout @@ -22,8 +29,9 @@ struct GitService { /// Run a git command, capturing stderr too private func runWithStderr(_ arguments: [String]) throws -> (stdout: String, stderr: String, success: Bool) { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = arguments + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + process.currentDirectoryURL = workingDirectoryURL let stdout = Pipe() let stderr = Pipe() diff --git a/Sources/sprout/Services/JiraClient.swift b/Sources/sprout/Services/JiraClient.swift index d6a084e..e7a96b7 100644 --- a/Sources/sprout/Services/JiraClient.swift +++ b/Sources/sprout/Services/JiraClient.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Client for fetching tickets from Jira API struct JiraClient { @@ -49,7 +52,7 @@ struct JiraClient { case 404: throw SourceError.ticketNotFound(ticketId) default: - throw SourceError.networkError(URLError(.init(rawValue: httpResponse.statusCode))) + throw SourceError.networkError(URLError(.badServerResponse)) } // Parse response diff --git a/Tests/sproutTests/ConfigLoaderServiceTests.swift b/Tests/sproutTests/ConfigLoaderServiceTests.swift new file mode 100644 index 0000000..b5ae8fb --- /dev/null +++ b/Tests/sproutTests/ConfigLoaderServiceTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import sprout + +@Suite("Config loader") +struct ConfigLoaderServiceTests { + @Test("loads TOML config", .tags(.service)) + func configLoaderLoadsFile() throws { + try withTemporaryDirectory { dir in + let configFile = dir.appendingPathComponent("config.toml") + let toml = """ + [launch] + script = "echo hi" + """ + try toml.write(to: configFile, atomically: true, encoding: .utf8) + + let config = try ConfigLoader.load(from: configFile.path) + #expect(config.launch.script == "echo hi") + #expect(config.launch.resolvedPRScript == "echo hi") + } + } + + @Test("throws for missing file", .tags(.service)) + func configLoaderMissingFile() { + #expect(throws: ConfigError.self) { + try ConfigLoader.load(from: "/tmp/definitely-missing-sprout-config.toml") + } + } +} diff --git a/Tests/sproutTests/GitServiceTests.swift b/Tests/sproutTests/GitServiceTests.swift new file mode 100644 index 0000000..ddc8955 --- /dev/null +++ b/Tests/sproutTests/GitServiceTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import sprout + +@Suite("Git service") +struct GitServiceTests { + @Test("gets repo root and detects local branch", .tags(.service)) + func gitServiceRepoAndBranch() async throws { + try await withTemporaryDirectory { dir in + _ = try runProcess(["git", "init"], cwd: dir) + _ = try runProcess(["git", "config", "user.email", "tests@example.com"], cwd: dir) + _ = try runProcess(["git", "config", "user.name", "sprout-tests"], cwd: dir) + try "hello".write(to: dir.appendingPathComponent("README.md"), atomically: true, encoding: .utf8) + _ = try runProcess(["git", "add", "."], cwd: dir) + _ = try runProcess(["git", "commit", "-m", "init"], cwd: dir) + _ = try runProcess(["git", "checkout", "-b", "feature/test-branch"], cwd: dir) + + let service = GitService(workingDirectoryURL: dir) + let root = try await service.getRepoRoot() + #expect(root == dir.path) + #expect(try await service.branchExists("feature/test-branch")) + #expect(!(try await service.branchExists("missing/branch"))) + } + } + + @Test("parses GitHub remote URL formats", .tags(.service)) + func gitServiceRemoteParsing() async throws { + try await withTemporaryDirectory { dir in + _ = try runProcess(["git", "init"], cwd: dir) + + let service = GitService(workingDirectoryURL: dir) + + _ = try runProcess(["git", "remote", "add", "origin", "https://github.com/apple/swift.git"], cwd: dir) + #expect(try await service.getRemoteRepo() == "apple/swift") + + _ = try runProcess(["git", "remote", "set-url", "origin", "git@github.com:pointfreeco/swift-dependencies.git"], cwd: dir) + #expect(try await service.getRemoteRepo() == "pointfreeco/swift-dependencies") + } + } +} diff --git a/Tests/sproutTests/HooksServiceTests.swift b/Tests/sproutTests/HooksServiceTests.swift new file mode 100644 index 0000000..d711405 --- /dev/null +++ b/Tests/sproutTests/HooksServiceTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import sprout + +@Suite("Hooks service") +struct HooksServiceTests { + @Test("resolves main repo root from worktree .git file", .tags(.service)) + func hooksServiceFindMainRepoRoot() throws { + try withTemporaryDirectory { dir in + let worktree = dir.appendingPathComponent("worktree") + try FileManager.default.createDirectory(at: worktree, withIntermediateDirectories: true) + + let gitFile = worktree.appendingPathComponent(".git") + let content = "gitdir: \(dir.path)/.git/worktrees/feature-123\n" + try content.write(to: gitFile, atomically: true, encoding: .utf8) + + let hooks = HooksService() + #expect(hooks.findMainRepoRoot(from: worktree.path) == dir.path) + } + } +} diff --git a/Tests/sproutTests/InputDetectorTests.swift b/Tests/sproutTests/InputDetectorTests.swift new file mode 100644 index 0000000..21b4f96 --- /dev/null +++ b/Tests/sproutTests/InputDetectorTests.swift @@ -0,0 +1,17 @@ +import Testing +@testable import sprout + +@Suite("Input detector") +struct InputDetectorTests { + @Test("detects Jira, GitHub issue/PR, and raw prompt", .tags(.unit)) + func inputDetection() { + #expect(InputDetector.detect("IOS-123") == .jira("IOS-123")) + #expect(InputDetector.detect("#88") == .github("88", repo: nil)) + #expect(InputDetector.detect("gh:99") == .github("99", repo: nil)) + #expect(InputDetector.detect("pr:77") == .githubPR("77", repo: nil)) + #expect(InputDetector.detect("https://github.com/org/repo/issues/12") == .github("12", repo: "org/repo")) + #expect(InputDetector.detect("https://github.com/org/repo/pull/13") == .githubPR("13", repo: "org/repo")) + #expect(InputDetector.detect("https://acme.atlassian.net/browse/IOS-7") == .jira("IOS-7")) + #expect(InputDetector.detect("draft an architecture plan") == .rawPrompt("draft an architecture plan")) + } +} diff --git a/Tests/sproutTests/InterpolationTests.swift b/Tests/sproutTests/InterpolationTests.swift new file mode 100644 index 0000000..98844ae --- /dev/null +++ b/Tests/sproutTests/InterpolationTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import sprout + +@Suite("Interpolation") +struct InterpolationTests { + @Test("replaces known variables and preserves unknown", .tags(.unit)) + func interpolationBehavior() { + let template = "Hello {name}, ticket {id}, keep {unknown}." + let output = Interpolation.interpolate(template, with: ["name": "Ada", "id": "42"]) + #expect(output == "Hello Ada, ticket 42, keep {unknown}.") + } +} diff --git a/Tests/sproutTests/PromptComposerTests.swift b/Tests/sproutTests/PromptComposerTests.swift new file mode 100644 index 0000000..7903cc9 --- /dev/null +++ b/Tests/sproutTests/PromptComposerTests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import sprout + +@Suite("Prompt composer") +struct PromptComposerTests { + @Test("includes prefix/body/suffix with interpolation", .tags(.unit)) + func promptComposer() { + let config = PromptConfig(prefix: "User: {user}", template: "# {title}\n\n{description}", suffix: "Path: {worktree}") + let composer = PromptComposer(config: config) + let variables = [ + "user": "sam", + "title": "Implement cache", + "description": "Add LRU cache", + "worktree": "/tmp/worktrees/cache" + ] + + let output = composer.composeContent( + context: TicketContext(ticketId: "IOS-1", title: "Implement cache", description: "Add LRU cache", slug: "implement-cache"), + variables: variables + ) + + #expect(output.contains("User: sam")) + #expect(output.contains("# Implement cache")) + #expect(output.contains("Path: /tmp/worktrees/cache")) + } +} diff --git a/Tests/sproutTests/ScriptRunnerTests.swift b/Tests/sproutTests/ScriptRunnerTests.swift new file mode 100644 index 0000000..2d50845 --- /dev/null +++ b/Tests/sproutTests/ScriptRunnerTests.swift @@ -0,0 +1,15 @@ +import Testing +@testable import sprout + +@Suite("Script runner") +struct ScriptRunnerTests { + @Test("executes success and fails on non-zero exit", .tags(.service)) + func scriptRunnerBehavior() async throws { + let runner = ScriptRunner() + try await runner.execute("echo ok", verbose: false) + + await #expect(throws: ScriptError.self) { + try await runner.execute("exit 7", verbose: false) + } + } +} diff --git a/Tests/sproutTests/SlugifyTests.swift b/Tests/sproutTests/SlugifyTests.swift new file mode 100644 index 0000000..1ef1c5f --- /dev/null +++ b/Tests/sproutTests/SlugifyTests.swift @@ -0,0 +1,18 @@ +import Testing +@testable import sprout + +@Suite("Slugify") +struct SlugifyTests { + @Test("normalizes text and punctuation", .tags(.unit)) + func slugifyBasic() { + #expect(Slugify.slugify("Fix Login_Button NOW!!!") == "fix-login-button-now") + } + + @Test("truncates long output", .tags(.unit)) + func slugifyTruncates() { + let input = String(repeating: "abc_", count: 30) + let slug = Slugify.slugify(input) + #expect(slug.count <= 50) + #expect(!slug.hasSuffix("-")) + } +} diff --git a/Tests/sproutTests/TestSupport.swift b/Tests/sproutTests/TestSupport.swift new file mode 100644 index 0000000..468a00e --- /dev/null +++ b/Tests/sproutTests/TestSupport.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing + +struct TemporaryDirectory { + let url: URL + + init(prefix: String = "sprout-tests-", function: StaticString = #function) throws { + let cleaned = String(describing: function) + .replacingOccurrences(of: "(", with: "") + .replacingOccurrences(of: ")", with: "") + .replacingOccurrences(of: ".", with: "") + .replacingOccurrences(of: ":", with: "_") + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)\(cleaned)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.url = dir + } + + func cleanup() { + try? FileManager.default.removeItem(at: url) + } +} + +@discardableResult +func withTemporaryDirectory( + function: StaticString = #function, + _ body: (URL) throws -> T +) throws -> T { + let temp = try TemporaryDirectory(function: function) + defer { temp.cleanup() } + return try body(temp.url) +} + +@discardableResult +func withTemporaryDirectory( + function: StaticString = #function, + _ body: (URL) async throws -> T +) async throws -> T { + let temp = try TemporaryDirectory(function: function) + defer { temp.cleanup() } + return try await body(temp.url) +} + +extension Trait where Self == ConditionTrait { + static func requires(executable tool: String) -> Self { + enabled("requires '\(tool)' to be available on PATH") { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["sh", "-lc", "command -v \"\(tool)\" >/dev/null 2>&1"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + } +} + +@discardableResult +func runProcess(_ arguments: [String], cwd: URL? = nil) throws -> (Int32, String, String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = arguments + if let cwd { + process.currentDirectoryURL = cwd + } + + let out = Pipe() + let err = Pipe() + process.standardOutput = out + process.standardError = err + + try process.run() + process.waitUntilExit() + + let outStr = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let errStr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, outStr, errStr) +} diff --git a/Tests/sproutTests/TestTags.swift b/Tests/sproutTests/TestTags.swift new file mode 100644 index 0000000..806f770 --- /dev/null +++ b/Tests/sproutTests/TestTags.swift @@ -0,0 +1,6 @@ +import Testing + +extension Tag { + @Tag static var unit: Self + @Tag static var service: Self +} From 016ce876677a6722bca2b3fb82cdd432a6feff11 Mon Sep 17 00:00:00 2001 From: Gage Halverson Date: Fri, 6 Feb 2026 23:17:45 -0700 Subject: [PATCH 2/5] Add GitHub Actions workflow for Swift build and tests --- .github/workflows/swift-tests.yml | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/swift-tests.yml diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml new file mode 100644 index 0000000..d1dd09c --- /dev/null +++ b/.github/workflows/swift-tests.yml @@ -0,0 +1,39 @@ +name: Swift Tests + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + test: + name: Swift ${{ matrix.swift }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + swift: ['6.1'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: ${{ matrix.swift }} + + - name: Show Swift version + run: swift --version + + - name: Resolve dependencies + run: swift package resolve + + - name: Build + run: swift build + + - name: Test + run: swift test From 14f3c68947bb95ff0387343bc01048794e718c8f Mon Sep 17 00:00:00 2001 From: Gage Halverson Date: Fri, 6 Feb 2026 23:21:43 -0700 Subject: [PATCH 3/5] Use Swift 6.2 in CI to satisfy dependency tools version --- .github/workflows/swift-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index d1dd09c..5960999 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - swift: ['6.1'] + swift: ['6.2'] steps: - name: Checkout From a0cba3b1e143009a22dfdd87f7a06610d4f5439e Mon Sep 17 00:00:00 2001 From: Gage Halverson Date: Fri, 6 Feb 2026 23:27:35 -0700 Subject: [PATCH 4/5] Run CI matrix on Linux and macOS --- .github/workflows/swift-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 5960999..af3f769 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest] + os: [ubuntu-latest, macos-latest] swift: ['6.2'] steps: From a02a2eabdf4c87f1c44848c4190af18e07196961 Mon Sep 17 00:00:00 2001 From: Gage Halverson Date: Fri, 6 Feb 2026 23:32:43 -0700 Subject: [PATCH 5/5] Normalize repo root paths in GitService test for macOS --- Tests/sproutTests/GitServiceTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/sproutTests/GitServiceTests.swift b/Tests/sproutTests/GitServiceTests.swift index ddc8955..ee0e86e 100644 --- a/Tests/sproutTests/GitServiceTests.swift +++ b/Tests/sproutTests/GitServiceTests.swift @@ -17,7 +17,9 @@ struct GitServiceTests { let service = GitService(workingDirectoryURL: dir) let root = try await service.getRepoRoot() - #expect(root == dir.path) + let normalizedRoot = URL(fileURLWithPath: root).resolvingSymlinksInPath().path + let normalizedDir = dir.resolvingSymlinksInPath().path + #expect(normalizedRoot == normalizedDir) #expect(try await service.branchExists("feature/test-branch")) #expect(!(try await service.branchExists("missing/branch"))) }