native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413import Foundationimport Testing
@testable import Prowl
actor OutgoingChangesShellCallStore { private(set) var calls: [[String]] = []
func record(_ arguments: [String]) { calls.append(arguments) }}
struct GitOutgoingChangesTests { @Test func pullRequestBaseResolvesFullyQualifiedTargetRemoteRefIncludingEnterpriseHostAndSSHPort() async throws { let store = OutgoingChangesShellCallStore() let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) if arguments.contains("get-url") { switch arguments.last { case "origin": return ShellOutput(stdout: "git@ghe.example:fork/project.git\n", stderr: "", exitCode: 0) case "upstream": return ShellOutput( stdout: "ssh://git@ghe.example:2222/target/project.git\n", stderr: "", exitCode: 0 ) default: return ShellOutput(stdout: "", stderr: "", exitCode: 0) } } if arguments.contains("remote") { return ShellOutput(stdout: "origin\nupstream\n", stderr: "", exitCode: 0) } if arguments.contains("rev-parse") { return ShellOutput(stdout: "0123456789abcdef\n", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell)
let resolution = try await client.outgoingBaseResolution( pullRequest: GitPullRequestBase( url: "https://ghe.example/target/project/pull/42", baseRefName: "main" ), configuredBaseRef: nil, in: URL(fileURLWithPath: "/tmp/repo") )
#expect(resolution.ref == "refs/remotes/upstream/main") #expect(resolution.displayName == "upstream/main") #expect(resolution.source == .pullRequest) let calls = await store.calls #expect(calls.contains { $0.contains("refs/remotes/upstream/main") }) // The short name must never reach rev-parse/merge-base, or a local branch // literally named `upstream/main` would win Git's disambiguation. #expect( !calls.contains { arguments in arguments.contains("rev-parse") && arguments.contains("upstream/main") } ) }
@Test func pullRequestBaseErrorsWhenNoRemoteMatchesInsteadOfFallingBackToOrigin() async { let shell = ShellClient( run: { _, arguments, _ in if arguments.contains("get-url") { return ShellOutput(stdout: "git@github.com:fork/project.git\n", stderr: "", exitCode: 0) } if arguments.contains("remote") { return ShellOutput(stdout: "origin\n", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell)
await #expect( throws: OutgoingBaseResolutionError.noMatchingRemote(host: "github.com", repositoryPath: "upstream/project") ) { try await client.outgoingBaseResolution( pullRequest: GitPullRequestBase( url: "https://github.com/upstream/project/pull/42", baseRefName: "main" ), configuredBaseRef: nil, in: URL(fileURLWithPath: "/tmp/repo") ) } }
@Test func pullRequestBaseErrorsWhenMultipleRemotesMatchAndNamesThem() async { let shell = ShellClient( run: { _, arguments, _ in if arguments.contains("get-url") { return ShellOutput(stdout: "git@github.com:upstream/project.git\n", stderr: "", exitCode: 0) } if arguments.contains("remote") { return ShellOutput(stdout: "origin\nmirror\n", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell)
await #expect(throws: OutgoingBaseResolutionError.multipleMatchingRemotes(["mirror", "origin"])) { try await client.outgoingBaseResolution( pullRequest: GitPullRequestBase( url: "https://github.com/upstream/project/pull/42", baseRefName: "main" ), configuredBaseRef: nil, in: URL(fileURLWithPath: "/tmp/repo") ) } }
@Test func pullRequestBaseErrorsWhenBaseRefIsNotFetchedInsteadOfCascading() async { let shell = ShellClient( run: { _, arguments, _ in if arguments.contains("get-url") { return ShellOutput(stdout: "git@github.com:upstream/project.git\n", stderr: "", exitCode: 0) } if arguments.contains("remote") { return ShellOutput(stdout: "origin\n", stderr: "", exitCode: 0) } if arguments.contains("rev-parse") { throw ShellClientError(command: "git rev-parse", stdout: "", stderr: "fatal: bad revision", exitCode: 1) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell)
await #expect(throws: OutgoingBaseResolutionError.unresolvedPullRequestBase(remote: "origin", branch: "feature-a")) { try await client.outgoingBaseResolution( pullRequest: GitPullRequestBase( url: "https://github.com/upstream/project/pull/42", baseRefName: "feature-a" ), // A configured base must not rescue a present-but-unfetched PR base. configuredBaseRef: "origin/main", in: URL(fileURLWithPath: "/tmp/repo") ) } }
@Test func configuredBaseResolvesInRemoteNamespaceWhenNoPullRequestExists() async throws { let store = OutgoingChangesShellCallStore() let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) if arguments.contains("rev-parse") { if arguments.contains("refs/remotes/origin/develop") { return ShellOutput(stdout: "0123456789abcdef\n", stderr: "", exitCode: 0) } throw ShellClientError(command: "git rev-parse", stdout: "", stderr: "fatal: bad revision", exitCode: 1) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell)
let resolution = try await client.outgoingBaseResolution( pullRequest: nil, configuredBaseRef: "origin/develop", in: URL(fileURLWithPath: "/tmp/repo") )
#expect(resolution.ref == "refs/remotes/origin/develop") #expect(resolution.displayName == "origin/develop") #expect(resolution.source == .repositorySetting) }
@Test func configuredBaseErrorsWhenUnresolvableInsteadOfCascadingToAutomatic() async { let store = OutgoingChangesShellCallStore() let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) if arguments.contains("rev-parse") { throw ShellClientError(command: "git rev-parse", stdout: "", stderr: "fatal: bad revision", exitCode: 1) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell)
await #expect(throws: OutgoingBaseResolutionError.unresolvedRepositorySettingBase("origin/develop")) { try await client.outgoingBaseResolution( pullRequest: nil, configuredBaseRef: "origin/develop", in: URL(fileURLWithPath: "/tmp/repo") ) } let calls = await store.calls #expect(!calls.contains { $0.contains("symbolic-ref") }) }
@Test func automaticBaseIsUsedWhenNoPullRequestOrSettingExists() async throws { let shell = ShellClient( run: { _, arguments, _ in if arguments.contains("symbolic-ref") { return ShellOutput(stdout: "refs/remotes/origin/main\n", stderr: "", exitCode: 0) } if arguments.contains("rev-parse") { return ShellOutput(stdout: "0123456789abcdef\n", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell)
let resolution = try await client.outgoingBaseResolution( pullRequest: nil, configuredBaseRef: " ", in: URL(fileURLWithPath: "/tmp/repo") )
#expect(resolution.ref == "refs/remotes/origin/main") #expect(resolution.displayName == "origin/main") #expect(resolution.source == .automatic) }
@Test func outgoingDiffNameStatusUsesCapturedRevisionsAndPreservesUnicodePaths() async throws { let store = OutgoingChangesShellCallStore() let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) return ShellOutput(stdout: "M\t中文.swift\n", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell) let comparison = GitOutgoingChangesComparison( base: OutgoingBaseResolution( ref: "refs/remotes/upstream/main", displayName: "upstream/main", source: .pullRequest ), mergeBase: "merge-base-oid", head: "head-oid" )
let output = try await client.outgoingDiffNameStatus( for: comparison, at: URL(fileURLWithPath: "/tmp/repo") )
#expect(output.contains("中文.swift")) let calls = await store.calls #expect(calls.count == 1) let arguments = calls[0] #expect(arguments.contains("core.quotePath=false")) #expect(arguments.contains("merge-base-oid")) #expect(arguments.contains("head-oid")) }
@Test func outgoingComparisonUsesMergeBaseAndHeadAndExcludesWorkingTreeChanges() async throws { let repositoryURL = FileManager.default.temporaryDirectory.appending( path: "prowl-outgoing-changes-\(UUID().uuidString)", directoryHint: .isDirectory ) defer { try? FileManager.default.removeItem(at: repositoryURL) }
try runOutgoingChangesGit(["init", repositoryURL.path(percentEncoded: false)]) try runOutgoingChangesGit([ "-C", repositoryURL.path(percentEncoded: false), "config", "user.email", "test@example.com", ]) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "config", "user.name", "Test User"])
let trackedURL = repositoryURL.appending(path: "tracked.txt") try "base\n".write(to: trackedURL, atomically: true, encoding: .utf8) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "add", "tracked.txt"]) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "commit", "-m", "base"]) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "branch", "-M", "main"])
try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "checkout", "-b", "feature"]) try "feature\n".write(to: trackedURL, atomically: true, encoding: .utf8) try "feature only\n".write( to: repositoryURL.appending(path: "feature-only.txt"), atomically: true, encoding: .utf8 ) try runOutgoingChangesGit([ "-C", repositoryURL.path(percentEncoded: false), "add", "tracked.txt", "feature-only.txt", ]) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "commit", "-m", "feature"])
try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "checkout", "main"]) try "main advanced\n".write(to: trackedURL, atomically: true, encoding: .utf8) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "add", "tracked.txt"]) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "commit", "-m", "main advanced"]) try runOutgoingChangesGit(["-C", repositoryURL.path(percentEncoded: false), "checkout", "feature"])
try "uncommitted\n".write(to: trackedURL, atomically: true, encoding: .utf8) try "untracked\n".write( to: repositoryURL.appending(path: "untracked.txt"), atomically: true, encoding: .utf8 )
let client = GitClient() let resolution = try await client.outgoingBaseResolution( pullRequest: nil, configuredBaseRef: "main", in: repositoryURL ) let comparison = try await client.outgoingChangesComparison(base: resolution, at: repositoryURL) let files = DiffChangedFile.parseNameStatus( try await client.outgoingDiffNameStatus(for: comparison, at: repositoryURL) ) let oldContents = await client.showFile("tracked.txt", at: comparison.mergeBase, in: repositoryURL) let newContents = await client.showFile("tracked.txt", at: comparison.head, in: repositoryURL)
#expect(resolution.ref == "refs/heads/main") #expect(resolution.displayName == "main") #expect(resolution.source == .repositorySetting) #expect(files.map(\.displayPath).sorted() == ["feature-only.txt", "tracked.txt"]) #expect(!files.contains { $0.displayPath == "untracked.txt" }) #expect(oldContents == "base") #expect(newContents == "feature") }
@Test func pullRequestBaseIgnoresLocalBranchShadowingTheRemoteTrackingRef() async throws { let repositoryURL = FileManager.default.temporaryDirectory.appending( path: "prowl-outgoing-shadow-\(UUID().uuidString)", directoryHint: .isDirectory ) defer { try? FileManager.default.removeItem(at: repositoryURL) } let path = repositoryURL.path(percentEncoded: false)
try runOutgoingChangesGit(["init", path]) try runOutgoingChangesGit(["-C", path, "config", "user.email", "test@example.com"]) try runOutgoingChangesGit(["-C", path, "config", "user.name", "Test User"])
try "base\n".write(to: repositoryURL.appending(path: "file.txt"), atomically: true, encoding: .utf8) try runOutgoingChangesGit(["-C", path, "add", "file.txt"]) try runOutgoingChangesGit(["-C", path, "commit", "-m", "base"]) try runOutgoingChangesGit(["-C", path, "branch", "-M", "main"]) let baseCommit = try runOutgoingChangesGit(["-C", path, "rev-parse", "HEAD"]) .trimmingCharacters(in: .whitespacesAndNewlines)
try runOutgoingChangesGit(["-C", path, "checkout", "-b", "feature"]) try "feature\n".write(to: repositoryURL.appending(path: "file.txt"), atomically: true, encoding: .utf8) try runOutgoingChangesGit(["-C", path, "add", "file.txt"]) try runOutgoingChangesGit(["-C", path, "commit", "-m", "feature"]) let featureCommit = try runOutgoingChangesGit(["-C", path, "rev-parse", "HEAD"]) .trimmingCharacters(in: .whitespacesAndNewlines)
try runOutgoingChangesGit(["-C", path, "remote", "add", "upstream", "https://github.com/target/project.git"]) // The remote-tracking base sits at the base commit... try runOutgoingChangesGit(["-C", path, "update-ref", "refs/remotes/upstream/main", baseCommit]) // ...while a local branch literally named `upstream/main` sits at the // feature tip. Short-name disambiguation would pick the local branch and // report an empty outgoing diff. try runOutgoingChangesGit(["-C", path, "branch", "upstream/main", featureCommit])
let client = GitClient() let resolution = try await client.outgoingBaseResolution( pullRequest: GitPullRequestBase( url: "https://github.com/target/project/pull/1", baseRefName: "main" ), configuredBaseRef: nil, in: repositoryURL ) let comparison = try await client.outgoingChangesComparison(base: resolution, at: repositoryURL) let files = DiffChangedFile.parseNameStatus( try await client.outgoingDiffNameStatus(for: comparison, at: repositoryURL) )
#expect(resolution.ref == "refs/remotes/upstream/main") #expect(comparison.mergeBase == baseCommit) #expect(files.map(\.displayPath) == ["file.txt"]) }}
private struct OutgoingChangesGitCommandError: Error { let output: String}
@discardableResultprivate func runOutgoingChangesGit(_ arguments: [String]) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = arguments var environment = ProcessInfo.processInfo.environment environment["GIT_CONFIG_GLOBAL"] = "/dev/null" process.environment = environment let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe try process.run() process.waitUntilExit() let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" guard process.terminationStatus == 0 else { throw OutgoingChangesGitCommandError(output: output) } return output}