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
39 changes: 39 additions & 0 deletions .github/workflows/swift-tests.yml
Original file line number Diff line number Diff line change
@@ -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, macos-latest]
swift: ['6.2']

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
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,9 @@ let package = Package(
.product(name: "FuzzyTUI", package: "tui-fuzzy-finder"),
]
),
.testTarget(
name: "sproutTests",
dependencies: ["sprout"]
),
]
)
3 changes: 0 additions & 3 deletions Sources/sprout/Commands/Prune.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import ArgumentParser
import Darwin
import Foundation
import FuzzyTUI

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/sprout/Models/InputSource.swift
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
5 changes: 4 additions & 1 deletion Sources/sprout/Services/GitHubClient.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Client for fetching issues from GitHub API
struct GitHubClient {
Expand Down Expand Up @@ -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))
}
}
}
Expand Down
16 changes: 12 additions & 4 deletions Sources/sprout/Services/GitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion Sources/sprout/Services/JiraClient.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Client for fetching tickets from Jira API
struct JiraClient {
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions Tests/sproutTests/ConfigLoaderServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
42 changes: 42 additions & 0 deletions Tests/sproutTests/GitServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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()
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")))
}
}

@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")
}
}
}
21 changes: 21 additions & 0 deletions Tests/sproutTests/HooksServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
17 changes: 17 additions & 0 deletions Tests/sproutTests/InputDetectorTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
12 changes: 12 additions & 0 deletions Tests/sproutTests/InterpolationTests.swift
Original file line number Diff line number Diff line change
@@ -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}.")
}
}
26 changes: 26 additions & 0 deletions Tests/sproutTests/PromptComposerTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
15 changes: 15 additions & 0 deletions Tests/sproutTests/ScriptRunnerTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
18 changes: 18 additions & 0 deletions Tests/sproutTests/SlugifyTests.swift
Original file line number Diff line number Diff line change
@@ -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("-"))
}
}
Loading