import Dependencies import Foundation import SourceControlClient import SourceControlClientGitTestSupport import 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.. String { try runGitCommand(["-C", repositoryURL.path()] + arguments) } } @discardableResult private 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") }