Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
swift-dependencies-source-control-client Tests SourceControlClientGitTests SourceControlClientGitTests.swift
25 kB · 629 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630import Dependenciesimport Foundationimport SourceControlClientimport SourceControlClientGitTestSupportimport Testing
@testable import SourceControlClientGit
@Suite(.sourceControlClient(.libGit2), .serialized)struct SourceControlClientGitTests { @Test(arguments: [ ( "SSH could not read data: Timed out waiting on socket", SourceControlRemoteFailure.Kind.timedOut ), ( "failed to authenticate SSH session: Timed out waiting on socket", SourceControlRemoteFailure.Kind.timedOut ), ( "failed to start SSH session", SourceControlRemoteFailure.Kind.connection ), ( "failed to authenticate SSH session", SourceControlRemoteFailure.Kind.authentication ), ( "unexpected remote transport failure", SourceControlRemoteFailure.Kind.unknown ), ]) func remoteFailureClassifiesTransportKindWithoutDiscardingMessage( message: String, expectedKind: SourceControlRemoteFailure.Kind ) { let failure = sourceControlRemoteFailure(message: message)
#expect(failure.kind == expectedKind) #expect(failure.message == message) }
@Test func cloneHonorsTaskCancellationBeforeStarting() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let destinationURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-git-cancelled-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: destinationURL) }
let cloneTask = Task { withUnsafeCurrentTask { $0?.cancel() } return try await sourceControl.clone( CloneRequest( remote: Remote(url: RemoteURL(rawValue: fixture.repositoryURL.path())), destinationURL: destinationURL ) ) }
var receivedCancellationError = false do { _ = try await cloneTask.value Issue.record("A cancelled clone must not start source-control work.") } catch is CancellationError { receivedCancellationError = true } catch { Issue.record("Expected CancellationError, received \(error).") } #expect(receivedCancellationError) #expect(!FileManager.default.fileExists(atPath: destinationURL.path())) }
@Test func opensExistingRepository() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl
let gitRepository = try GitRepository.open(at: fixture.repositoryURL) #expect(gitRepository == GitRepository(url: fixture.repositoryURL))
let repository = try await sourceControl.open(fixture.repositoryURL)
#expect(repository == Repository(url: fixture.repositoryURL)) }
@Test func resolvesHeadBranchAndAnnotatedTagToTheirExactCommit() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let repository = Repository(url: fixture.repositoryURL) let expectedRevision = try fixture.runGit(["rev-parse", "HEAD"]) .trimmingCharacters(in: .whitespacesAndNewlines) try fixture.runGit(["tag", "-a", "release", "-m", "Release"])
let head = try await sourceControl.resolveRevision( ResolveRevisionRequest(repository: repository) ) let branch = try await sourceControl.resolveRevision( ResolveRevisionRequest(repository: repository, ref: "main") ) let tag = try await sourceControl.resolveRevision( ResolveRevisionRequest(repository: repository, ref: "release") )
#expect(head.rawValue == expectedRevision) #expect(branch == head) #expect(tag == head) }
@Test func clonesLocalRepositoryAndChecksOutRequestedBranch() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl try fixture.runGit(["checkout", "-b", "release"]) try fixture.writeFile(path: "event.yml", contents: "name: Release\n") try fixture.runGit(["add", "event.yml"]) try fixture.runGit(["commit", "-m", "release"])
let destinationURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-git-clone-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: destinationURL) }
let repository = try await sourceControl.clone( CloneRequest( remote: Remote(url: RemoteURL(rawValue: fixture.repositoryURL.path())), destinationURL: destinationURL, checkout: "release" ) )
#expect(repository == Repository(url: destinationURL)) let contents = try String(contentsOf: destinationURL.appending(path: "event.yml"), encoding: .utf8) #expect(contents == "name: Release\n") }
@Test func cloneOpenStageAndCommitUseOneFilesystemPathWhenAnAncestorContainsSpaces() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let rootURL = FileManager.default.temporaryDirectory .appending( path: "Source Control Client Git \(UUID().uuidString)", directoryHint: .isDirectory ) let destinationURL = rootURL .appending(path: "repository", directoryHint: .isDirectory) let incorrectlyEncodedRootURL = URL( filePath: rootURL.path(percentEncoded: true) ) defer { try? FileManager.default.removeItem(at: rootURL) try? FileManager.default.removeItem(at: incorrectlyEncodedRootURL) } try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
_ = try await sourceControl.clone( CloneRequest( remote: Remote( url: RemoteURL(rawValue: fixture.repositoryURL.path(percentEncoded: false)) ), destinationURL: destinationURL, checkout: "main" ) )
try #require( FileManager.default.fileExists( atPath: destinationURL .appending(path: ".git", directoryHint: .isDirectory) .path(percentEncoded: false) ), "Clone must install the repository at the filesystem location identified by destinationURL." )
let repository = try await sourceControl.open(destinationURL) try "name: Proposal\n".write( to: destinationURL.appending(path: "proposal.yml"), atomically: true, encoding: .utf8 ) try await sourceControl.stage( StageRequest(repository: repository, paths: ["proposal.yml"]) ) _ = try await sourceControl.commit( CommitRequest( repository: repository, message: "Add proposal", author: Author( name: "Open Music Event", email: "noreply@openmusicevent.app" ) ) )
#expect(try await sourceControl.status(repository).isEmpty) #expect( !FileManager.default.fileExists( atPath: incorrectlyEncodedRootURL .appending(path: "repository/.git", directoryHint: .isDirectory) .path(percentEncoded: false) ) ) }
@Test(.disabled("Ubuntu libgit2 local file transport does not reliably produce shallow clones.")) func clonePassesRequestedShallowDepthToLibGit2() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl try fixture.writeFile(path: "second.yml", contents: "name: Second\n") try fixture.runGit(["add", "second.yml"]) try fixture.runGit(["commit", "-m", "second"]) try fixture.writeFile(path: "third.yml", contents: "name: Third\n") try fixture.runGit(["add", "third.yml"]) try fixture.runGit(["commit", "-m", "third"])
let destinationURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-git-shallow-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: destinationURL) }
let request = CloneRequest( remote: Remote(url: RemoteURL(rawValue: fixture.repositoryURL.absoluteString)), destinationURL: destinationURL, checkout: "main", depth: 1 )
do { let repository = try await sourceControl.clone(request)
#expect(repository == Repository(url: destinationURL)) #expect( try runGitCommand(["-C", destinationURL.path(), "rev-parse", "--is-shallow-repository"]) .trimmingCharacters(in: .whitespacesAndNewlines) == "true" ) #expect( try runGitCommand(["-C", destinationURL.path(), "rev-list", "--count", "HEAD"]) .trimmingCharacters(in: .whitespacesAndNewlines) == "1" ) } catch SourceControlError.remoteOperationFailed(let failure) { #expect(failure.kind == .unknown) #expect(failure.message.contains("shallow fetch")) #expect(failure.message.contains("local transport")) } }
@Test func createsBranchStagesCommitsAndReportsStatus() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let repository = Repository(url: fixture.repositoryURL)
let branch = try await sourceControl.createBranch( CreateBranchRequest(repository: repository, name: "proposal/test", startPoint: "HEAD") ) #expect(branch.name == "proposal/test")
try fixture.writeFile(path: "proposal.yml", contents: "name: Proposal\n") var status = try await sourceControl.status(repository) #expect(status.contains(StatusEntry(path: "proposal.yml", kind: .untracked)))
try await sourceControl.stage(StageRequest(repository: repository, paths: ["proposal.yml"])) _ = try await sourceControl.commit( CommitRequest( repository: repository, message: "Add proposal", author: Author(name: "Open Music Event", email: "noreply@openmusicevent.app") ) )
status = try await sourceControl.status(repository) #expect(status.isEmpty) #expect(try fixture.runGit(["rev-parse", "--abbrev-ref", "HEAD"]).trimmingCharacters(in: .whitespacesAndNewlines) == "proposal/test") }
@Test func formatsCommitPatchWithBinaryFileContents() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let repository = Repository(url: fixture.repositoryURL)
try fixture.writeData(path: "images/poster.bin", contents: Data([0x00, 0x01, 0x02, 0xff])) try await sourceControl.stage(StageRequest(repository: repository, paths: ["images/poster.bin"])) let commit = try await sourceControl.commit( CommitRequest( repository: repository, message: "Add poster", author: Author(name: "Open Music Event", email: "noreply@openmusicevent.app") ) )
let patch = try await sourceControl.formatPatch( FormatPatchRequest(repository: repository, commit: commit.id) ) let patchText = try #require(String(data: patch, encoding: .utf8))
#expect(patchText.contains("diff --git a/images/poster.bin b/images/poster.bin\n")) #expect(patchText.contains("GIT binary patch\n")) #expect(patchText.contains("literal 4\n")) }
@Test func `formats the complete current tree against an explicit base`() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let repository = Repository(url: fixture.repositoryURL) try fixture.writeFile(path: "removed.yml", contents: "remove: true\n") try fixture.runGit(["add", "removed.yml"]) try fixture.runGit(["commit", "-m", "establish proposal base"]) let baseCommit = CommitID( rawValue: try fixture.runGit(["rev-parse", "HEAD"]) .trimmingCharacters(in: .whitespacesAndNewlines) )
// The first proposal round changes the event name. try fixture.writeFile(path: "event.yml", contents: "name: First edit\n") try await sourceControl.stage( StageRequest(repository: repository, paths: ["event.yml"]) ) _ = try await sourceControl.commit( CommitRequest( repository: repository, message: "First edit", author: Author( name: "Open Music Event", email: "noreply@openmusicevent.app" ) ) )
// The replacement round adds a second edit, a deletion, and binary content. try fixture.writeFile(path: "schedule.yml", contents: "stages: 2\n") try FileManager.default.removeItem( at: fixture.repositoryURL.appending(path: "removed.yml") ) try fixture.writeData( path: "images/poster.bin", contents: Data([0x00, 0x01, 0x02, 0xff]) ) try await sourceControl.stage( StageRequest( repository: repository, paths: ["images/poster.bin", "removed.yml", "schedule.yml"] ) ) let currentCommit = try await sourceControl.commit( CommitRequest( repository: repository, message: "Complete replacement round", author: Author( name: "Open Music Event", email: "noreply@openmusicevent.app" ) ) )
// Formatting round two against the immutable base must include both rounds by itself. let patch = try await sourceControl.emailPatch( EmailPatchRequest( repository: repository, commit: currentCommit.id, baseCommit: baseCommit ) )
let appliedURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-replacement-patch-\(UUID().uuidString)") let patchURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-replacement-patch-\(UUID().uuidString).patch") defer { try? FileManager.default.removeItem(at: appliedURL) try? FileManager.default.removeItem(at: patchURL) } try runGitCommand(["clone", fixture.repositoryURL.path(), appliedURL.path()]) try runGitCommand(["-C", appliedURL.path(), "checkout", baseCommit.rawValue]) try patch.write(to: patchURL) try runGitCommand([ "-C", appliedURL.path(), "-c", "user.name=Open Music Event", "-c", "user.email=noreply@openmusicevent.app", "am", patchURL.path(), ])
#expect(String(decoding: patch, as: UTF8.self).hasPrefix("From \(currentCommit.id.rawValue) ")) #expect( try String( contentsOf: appliedURL.appending(path: "event.yml"), encoding: .utf8 ) == "name: First edit\n" ) #expect( try String( contentsOf: appliedURL.appending(path: "schedule.yml"), encoding: .utf8 ) == "stages: 2\n" ) #expect( try Data(contentsOf: appliedURL.appending(path: "images/poster.bin")) == Data([0x00, 0x01, 0x02, 0xff]) ) #expect( !FileManager.default.fileExists( atPath: appliedURL.appending(path: "removed.yml").path() ) ) }
@Test func pushesAndFetchesLocalBareRemote() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let bareRemoteURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-git-remote-\(UUID().uuidString)") let cloneURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-git-fetch-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: bareRemoteURL) try? FileManager.default.removeItem(at: cloneURL) }
try runGitCommand(["init", "--bare", bareRemoteURL.path()]) try fixture.runGit(["remote", "add", "origin", bareRemoteURL.path()])
try await sourceControl.push( PushRequest( repository: Repository(url: fixture.repositoryURL), localBranch: "main", remoteBranch: "main" ) )
_ = try await sourceControl.clone( CloneRequest( remote: Remote(url: RemoteURL(rawValue: bareRemoteURL.path())), destinationURL: cloneURL, checkout: "main" ) )
try fixture.writeFile(path: "event.yml", contents: "name: Updated\n") try fixture.runGit(["add", "event.yml"]) try fixture.runGit(["commit", "-m", "update"]) try await sourceControl.push( PushRequest( repository: Repository(url: fixture.repositoryURL), localBranch: "main", remoteBranch: "main" ) )
try await sourceControl.fetch(FetchRequest(repository: Repository(url: cloneURL))) let fetchedHead = try runGitCommand(["-C", cloneURL.path(), "rev-parse", "origin/main"]) .trimmingCharacters(in: .whitespacesAndNewlines) let sourceHead = try fixture.runGit(["rev-parse", "HEAD"]) .trimmingCharacters(in: .whitespacesAndNewlines)
#expect(fetchedHead == sourceHead) }
@Test func pushReportsTheRemoteRejectionReason() async throws { let payload = GitRemoteCallbacksPayload(credentials: nil) let context = Unmanaged.passUnretained(payload).toOpaque()
"permission denied for OME collaborator\n".withCString { message in _ = gitSidebandProgressCallback( message, Int32(strlen(message)), context ) } "refs/heads/ome/proposal".withCString { refname in "pre-receive hook declined".withCString { status in _ = gitPushUpdateReferenceCallback(refname, status, context) } }
#expect( payload.pushFailureMessage(fallback: "unrecoverable internal error: 'eos'") == """ permission denied for OME collaborator Remote rejected refs/heads/ome/proposal: pre-receive hook declined unrecoverable internal error: 'eos' """ )
do { try checkRemotePush(0, payload: payload) Issue.record("A rejected push must surface the remote's reason.") } catch SourceControlError.remoteOperationFailed(let failure) { #expect(failure.kind == .authentication) #expect(failure.message.contains("permission denied for OME collaborator")) #expect( failure.message.contains( "Remote rejected refs/heads/ome/proposal: pre-receive hook declined" ) ) } }
@Test func serializesConcurrentOperationsForOneCheckout() async throws { let fixture = try LocalGitFixture() @Dependency(\.sourceControl) var sourceControl let createBranch = sourceControl.createBranch let repository = Repository(url: fixture.repositoryURL) let count = 25
try await withThrowingTaskGroup(of: Void.self) { group in for index in 0..<count { group.addTask { _ = try await createBranch( CreateBranchRequest( repository: repository, name: BranchName(rawValue: "proposal/\(index)"), startPoint: "HEAD", checkout: false ) ) } } try await group.waitForAll() }
let branches = try fixture.runGit(["branch", "--list", "proposal/*"]) .split(separator: "\n") #expect(branches.count == count) }
@Test func liveSSHCloneUsesProvidedMemoryCredentialWhenConfigured() async throws { let environment = ProcessInfo.processInfo.environment guard let remoteURL = environment["SOURCE_CONTROL_LIVE_SSH_URL"], !remoteURL.isEmpty, let privateKey = environment["SOURCE_CONTROL_LIVE_SSH_PRIVATE_KEY"], !privateKey.isEmpty else { return }
@Dependency(\.sourceControl) var sourceControl let publicKey = environment["SOURCE_CONTROL_LIVE_SSH_PUBLIC_KEY"] .flatMap { $0.isEmpty ? nil : SSHPublicKey(rawValue: normalizedEnvironmentValue($0)) } let destinationURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-live-ssh-\(UUID().uuidString)") .standardizedFileURL defer { try? FileManager.default.removeItem(at: destinationURL) }
let repository = try await sourceControl.clone( CloneRequest( remote: Remote(url: RemoteURL(rawValue: remoteURL)), destinationURL: destinationURL, credentials: Credentials( ssh: SSHCredentials( publicKey: publicKey, privateKey: SSHPrivateKey(rawValue: normalizedEnvironmentValue(privateKey)) ), hostKeyPolicy: .acceptForTesting ) ) )
#expect(repository == Repository(url: destinationURL)) #expect(FileManager.default.fileExists(atPath: destinationURL.appending(path: ".git").path())) }}
private struct LocalGitFixture { var repositoryURL: URL
init() throws { repositoryURL = FileManager.default.temporaryDirectory .appending(path: "source-control-client-git-fixture-\(UUID().uuidString)") .standardizedFileURL try FileManager.default.createDirectory(at: repositoryURL, withIntermediateDirectories: true) try runGit(["init"]) try runGit(["branch", "-M", "main"]) try runGit(["config", "user.name", "Open Music Event"]) try runGit(["config", "user.email", "noreply@openmusicevent.app"]) try writeFile(path: "event.yml", contents: "name: Test\n") try runGit(["add", "event.yml"]) try runGit(["commit", "-m", "initial"]) }
func writeFile(path: String, contents: String) throws { try contents.write(to: repositoryURL.appending(path: path), atomically: true, encoding: .utf8) }
func writeData(path: String, contents: Data) throws { let fileURL = repositoryURL.appending(path: path) try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try contents.write(to: fileURL) }
@discardableResult func runGit(_ arguments: [String]) throws -> String { try runGitCommand(["-C", repositoryURL.path()] + arguments) }}
@discardableResultprivate func runGitCommand(_ arguments: [String]) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = arguments
let output = Pipe() let error = Pipe() process.standardOutput = output process.standardError = error try process.run() process.waitUntilExit()
let outputData = output.fileHandleForReading.readDataToEndOfFile() let errorData = error.fileHandleForReading.readDataToEndOfFile() guard process.terminationStatus == 0 else { throw SourceControlError.operationFailed( String(decoding: outputData + errorData, as: UTF8.self) ) } return String(decoding: outputData, as: UTF8.self)}
private func normalizedEnvironmentValue(_ value: String) -> String { value.replacingOccurrences(of: "\\n", with: "\n")}