|
| 1 | +// |
| 2 | +// GitRepository.swift |
| 3 | +// pass |
| 4 | +// |
| 5 | +// Created by Mingshen Sun on 1/25/25. |
| 6 | +// Copyright © 2025 Bob Sun. All rights reserved. |
| 7 | +// |
| 8 | +import ObjectiveGit |
| 9 | + |
| 10 | +public typealias TransferProgressHandler = (UnsafePointer<git_transfer_progress>, UnsafeMutablePointer<ObjCBool>) -> Void |
| 11 | +public typealias CheckoutProgressHandler = (String, UInt, UInt) -> Void |
| 12 | +public typealias PushProgressHandler = (UInt32, UInt32, Int, UnsafeMutablePointer<ObjCBool>) -> Void |
| 13 | +public typealias CloneOptions = [AnyHashable: Any] |
| 14 | +public typealias PullOptions = [AnyHashable: Any] |
| 15 | +public typealias PushOptions = [String: Any] |
| 16 | + |
| 17 | +public class GitRepository { |
| 18 | + let repository: GTRepository |
| 19 | + var branchName: String = "master" |
| 20 | + |
| 21 | + public init(with localDir: URL) throws { |
| 22 | + guard FileManager.default.fileExists(atPath: localDir.path) else { |
| 23 | + throw AppError.repositoryNotSet |
| 24 | + } |
| 25 | + try self.repository = GTRepository(url: localDir) |
| 26 | + if let currentBranchName = try? repository.currentBranch().name { |
| 27 | + self.branchName = currentBranchName |
| 28 | + } |
| 29 | + } |
| 30 | + |
| 31 | + public init(from remoteURL: URL, to workingDir: URL, branchName: String, options: CloneOptions, transferProgressBlock: @escaping TransferProgressHandler, checkoutProgressBlock: @escaping CheckoutProgressHandler) throws { |
| 32 | + self.repository = try GTRepository.clone( |
| 33 | + from: remoteURL, |
| 34 | + toWorkingDirectory: workingDir, |
| 35 | + options: options, |
| 36 | + transferProgressBlock: transferProgressBlock |
| 37 | + ) |
| 38 | + self.branchName = branchName |
| 39 | + guard !repository.isHEADUnborn else { |
| 40 | + return |
| 41 | + } |
| 42 | + if (try repository.currentBranch().name) != branchName { |
| 43 | + try checkoutAndChangeBranch(branchName: branchName, progressBlock: checkoutProgressBlock) |
| 44 | + } |
| 45 | + } |
| 46 | + |
| 47 | + public func checkoutAndChangeBranch(branchName: String, progressBlock: @escaping CheckoutProgressHandler) throws { |
| 48 | + self.branchName = branchName |
| 49 | + if let localBranch = try? repository.lookUpBranch(withName: branchName, type: .local, success: nil) { |
| 50 | + let checkoutOptions = GTCheckoutOptions(strategy: .force, progressBlock: progressBlock) |
| 51 | + try repository.checkoutReference(localBranch.reference, options: checkoutOptions) |
| 52 | + try repository.moveHEAD(to: localBranch.reference) |
| 53 | + } else { |
| 54 | + let remoteBranchName = "origin/\(branchName)" |
| 55 | + let remoteBranch = try repository.lookUpBranch(withName: remoteBranchName, type: .remote, success: nil) |
| 56 | + guard let remoteBranchOid = remoteBranch.oid else { |
| 57 | + throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) |
| 58 | + } |
| 59 | + let localBranch = try repository.createBranchNamed(branchName, from: remoteBranchOid, message: nil) |
| 60 | + try localBranch.updateTrackingBranch(remoteBranch) |
| 61 | + let checkoutOptions = GTCheckoutOptions(strategy: .force, progressBlock: progressBlock) |
| 62 | + try repository.checkoutReference(localBranch.reference, options: checkoutOptions) |
| 63 | + try repository.moveHEAD(to: localBranch.reference) |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + public func pull( |
| 68 | + options: PullOptions, |
| 69 | + transferProgressBlock: @escaping TransferProgressHandler |
| 70 | + ) throws { |
| 71 | + let remote = try GTRemote(name: "origin", in: repository) |
| 72 | + try repository.pull(repository.currentBranch(), from: remote, withOptions: options, progress: transferProgressBlock) |
| 73 | + } |
| 74 | + |
| 75 | + public func getRecentCommits(count: Int) throws -> [GTCommit] { |
| 76 | + var commits = [GTCommit]() |
| 77 | + let enumerator = try GTEnumerator(repository: repository) |
| 78 | + if let targetOID = try repository.headReference().targetOID { |
| 79 | + try enumerator.pushSHA(targetOID.sha) |
| 80 | + } |
| 81 | + for _ in 0 ..< count { |
| 82 | + if let commit = try? enumerator.nextObject(withSuccess: nil) { |
| 83 | + commits.append(commit) |
| 84 | + } |
| 85 | + } |
| 86 | + return commits |
| 87 | + } |
| 88 | + |
| 89 | + public func add(path: String) throws { |
| 90 | + try repository.index().addFile(path) |
| 91 | + try repository.index().write() |
| 92 | + } |
| 93 | + |
| 94 | + public func rm(path: String) throws { |
| 95 | + guard let repoURL = repository.fileURL else { |
| 96 | + throw AppError.repositoryNotSet |
| 97 | + } |
| 98 | + |
| 99 | + let url = repoURL.appendingPathComponent(path) |
| 100 | + if FileManager.default.fileExists(atPath: url.path) { |
| 101 | + try FileManager.default.removeItem(at: url) |
| 102 | + } |
| 103 | + try repository.index().removeFile(path) |
| 104 | + try repository.index().write() |
| 105 | + } |
| 106 | + |
| 107 | + public func mv(from: String, to: String) throws { |
| 108 | + guard let repoURL = repository.fileURL else { |
| 109 | + throw AppError.repositoryNotSet |
| 110 | + } |
| 111 | + |
| 112 | + let fromURL = repoURL.appendingPathComponent(from) |
| 113 | + let toURL = repoURL.appendingPathComponent(to) |
| 114 | + try FileManager.default.moveItem(at: fromURL, to: toURL) |
| 115 | + try add(path: to) |
| 116 | + try rm(path: from) |
| 117 | + } |
| 118 | + |
| 119 | + public func commit(name: String, email: String, message: String) throws -> GTCommit { |
| 120 | + guard let signature = GTSignature(name: name, email: email, time: Date()) else { |
| 121 | + throw AppError.gitCreateSignature |
| 122 | + } |
| 123 | + return try commit(signature: signature, message: message) |
| 124 | + } |
| 125 | + |
| 126 | + public func commit(signature: GTSignature, message: String) throws -> GTCommit { |
| 127 | + let newTree = try repository.index().writeTree() |
| 128 | + if repository.isHEADUnborn { |
| 129 | + return try repository.createCommit(with: newTree, message: message, author: signature, committer: signature, parents: nil, updatingReferenceNamed: "HEAD") |
| 130 | + } |
| 131 | + let headReference = try repository.headReference() |
| 132 | + let commitEnum = try GTEnumerator(repository: repository) |
| 133 | + try commitEnum.pushSHA(headReference.targetOID!.sha) |
| 134 | + guard let parent = commitEnum.nextObject() as? GTCommit else { |
| 135 | + throw AppError.gitCommit |
| 136 | + } |
| 137 | + return try repository.createCommit(with: newTree, message: message, author: signature, committer: signature, parents: [parent], updatingReferenceNamed: headReference.name) |
| 138 | + } |
| 139 | + |
| 140 | + public func push( |
| 141 | + options: [String: Any], |
| 142 | + transferProgressBlock: @escaping PushProgressHandler |
| 143 | + ) throws { |
| 144 | + let branch = try repository.currentBranch() |
| 145 | + let remote = try GTRemote(name: "origin", in: repository) |
| 146 | + try repository.push(branch, to: remote, withOptions: options, progress: transferProgressBlock) |
| 147 | + } |
| 148 | + |
| 149 | + public func getLocalCommits() throws -> [GTCommit] { |
| 150 | + let remoteBranchName = "origin/\(branchName)" |
| 151 | + let remoteBranch = try repository.lookUpBranch(withName: remoteBranchName, type: .remote, success: nil) |
| 152 | + return try repository.localCommitsRelative(toRemoteBranch: remoteBranch) |
| 153 | + } |
| 154 | + |
| 155 | + public func numberOfCommits() -> Int { |
| 156 | + Int(repository.numberOfCommits(inCurrentBranch: nil)) |
| 157 | + } |
| 158 | + |
| 159 | + public func reset() throws { |
| 160 | + let localCommits = try getLocalCommits() |
| 161 | + if localCommits.isEmpty { |
| 162 | + return |
| 163 | + } |
| 164 | + guard let firstLocalCommit = localCommits.last, |
| 165 | + firstLocalCommit.parents.count == 1, |
| 166 | + let newHead = firstLocalCommit.parents.first else { |
| 167 | + throw AppError.gitReset |
| 168 | + } |
| 169 | + try repository.reset(to: newHead, resetType: .hard) |
| 170 | + } |
| 171 | + |
| 172 | + public func lastCommitDate(path: String) throws -> Date { |
| 173 | + let blameHunks = try repository.blame(withFile: path, options: nil).hunks |
| 174 | + guard let latestCommitTime = blameHunks.map({ $0.finalSignature?.time?.timeIntervalSince1970 ?? 0 }).max() else { |
| 175 | + return Date(timeIntervalSince1970: 0) |
| 176 | + } |
| 177 | + return Date(timeIntervalSince1970: latestCommitTime) |
| 178 | + } |
| 179 | +} |
0 commit comments