diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 2650037..ea5619f 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -198,6 +198,8 @@ DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; }; DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; }; DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; }; + DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; }; + DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */; }; DC7CBBBD2D0FA3F2003BB4D2 /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = DC7CBBBC2D0FA3F2003BB4D2 /* YubiKit */; }; DC7CBBBF2D0FAC92003BB4D2 /* YKFSmartCardInterfaceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */; }; DC8963C01E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift */; }; @@ -499,6 +501,8 @@ DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = ""; }; DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = ""; }; + DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = ""; }; + DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepositoryTest.swift; sourceTree = ""; }; DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YKFSmartCardInterfaceExtension.swift; sourceTree = ""; }; DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHKeyURLImportTableViewController.swift; sourceTree = ""; }; DC917BD31E2E8231000FDF54 /* Pass.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Pass.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -737,6 +741,7 @@ 30C015A7214ED378005BB6DF /* Models */ = { isa = PBXGroup; children = ( + DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */, 30695E2424FAEF2600C9D46E /* GitCredentialTest.swift */, 9ADC954024418A5F0005402E /* PasswordStoreTest.swift */, A2699ACE24027D9500F36323 /* PasswordTableEntryTest.swift */, @@ -916,6 +921,7 @@ A2F4E20E1EED7F040011986E /* Models */ = { isa = PBXGroup; children = ( + DC64745E2D45B23A004B4BBC /* GitRepository.swift */, 30697C4121F63CAB0064FCAC /* GitCredential.swift */, 30697C4221F63CAB0064FCAC /* PasscodeLock.swift */, 30697C4021F63CAB0064FCAC /* Password.swift */, @@ -1599,6 +1605,7 @@ 30697C2A21F63C5A0064FCAC /* NotificationNames.swift in Sources */, 30CCA91623258C380048CA51 /* PGPInterface.swift in Sources */, 30DAFD4A240985A7002456E7 /* Array+Slices.swift in Sources */, + DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */, 9A74D2E0277D2F8C00F7BC44 /* UIAlertControllerExtension.swift in Sources */, 30697C4721F63CAB0064FCAC /* PasscodeLock.swift in Sources */, A2699ACD2402631400F36323 /* PasswordTableEntry.swift in Sources */, @@ -1628,6 +1635,7 @@ 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, + DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */, A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, diff --git a/pass/Controllers/PasswordDetailTableViewController.swift b/pass/Controllers/PasswordDetailTableViewController.swift index 10db5e7..99e0a4a 100644 --- a/pass/Controllers/PasswordDetailTableViewController.swift +++ b/pass/Controllers/PasswordDetailTableViewController.swift @@ -593,7 +593,7 @@ extension PasswordDetailTableViewController { handleError(error: AppError.other(message: "PasswordDoesNotExist")) return } - let encryptedDataPath = PasswordStore.shared.storeURL.appendingPathComponent(passwordEntity.path) + let encryptedDataPath = passwordEntity.fileURL(in: PasswordStore.shared.storeURL) guard let encryptedData = try? Data(contentsOf: encryptedDataPath) else { handleError(error: AppError.other(message: "PasswordDoesNotExist")) diff --git a/pass/Controllers/PasswordNavigationViewController.swift b/pass/Controllers/PasswordNavigationViewController.swift index e1f2b90..ae3afe7 100644 --- a/pass/Controllers/PasswordNavigationViewController.swift +++ b/pass/Controllers/PasswordNavigationViewController.swift @@ -348,7 +348,7 @@ extension PasswordNavigationViewController { return false } } else if identifier == "addPasswordSegue" { - guard PGPAgent.shared.isPrepared, PasswordStore.shared.storeRepository != nil else { + guard PGPAgent.shared.isPrepared, PasswordStore.shared.gitRepository != nil else { Utils.alert(title: "CannotAddPassword".localize(), message: "MakeSurePgpAndGitProperlySet.".localize(), controller: self) return false } diff --git a/passKit/Helpers/AppError.swift b/passKit/Helpers/AppError.swift index 0459470..8e0aa21 100644 --- a/passKit/Helpers/AppError.swift +++ b/passKit/Helpers/AppError.swift @@ -16,6 +16,7 @@ public enum AppError: Error, Equatable { case readingFile(fileName: String) case passwordDuplicated case gitReset + case gitCommit case gitCreateSignature case gitPushNotSuccessful case pgpPublicKeyNotFound(keyID: String) diff --git a/passKit/Models/GitRepository.swift b/passKit/Models/GitRepository.swift new file mode 100644 index 0000000..c34fbec --- /dev/null +++ b/passKit/Models/GitRepository.swift @@ -0,0 +1,179 @@ +// +// GitRepository.swift +// pass +// +// Created by Mingshen Sun on 1/25/25. +// Copyright © 2025 Bob Sun. All rights reserved. +// +import ObjectiveGit + +public typealias TransferProgressHandler = (UnsafePointer, UnsafeMutablePointer) -> Void +public typealias CheckoutProgressHandler = (String, UInt, UInt) -> Void +public typealias PushProgressHandler = (UInt32, UInt32, Int, UnsafeMutablePointer) -> Void +public typealias CloneOptions = [AnyHashable: Any] +public typealias PullOptions = [AnyHashable: Any] +public typealias PushOptions = [String: Any] + +public class GitRepository { + let repository: GTRepository + var branchName: String = "master" + + public init(with localDir: URL) throws { + guard FileManager.default.fileExists(atPath: localDir.path) else { + throw AppError.repositoryNotSet + } + try self.repository = GTRepository(url: localDir) + if let currentBranchName = try? repository.currentBranch().name { + self.branchName = currentBranchName + } + } + + public init(from remoteURL: URL, to workingDir: URL, branchName: String, options: CloneOptions, transferProgressBlock: @escaping TransferProgressHandler, checkoutProgressBlock: @escaping CheckoutProgressHandler) throws { + self.repository = try GTRepository.clone( + from: remoteURL, + toWorkingDirectory: workingDir, + options: options, + transferProgressBlock: transferProgressBlock + ) + self.branchName = branchName + guard !repository.isHEADUnborn else { + return + } + if (try repository.currentBranch().name) != branchName { + try checkoutAndChangeBranch(branchName: branchName, progressBlock: checkoutProgressBlock) + } + } + + public func checkoutAndChangeBranch(branchName: String, progressBlock: @escaping CheckoutProgressHandler) throws { + self.branchName = branchName + if let localBranch = try? repository.lookUpBranch(withName: branchName, type: .local, success: nil) { + let checkoutOptions = GTCheckoutOptions(strategy: .force, progressBlock: progressBlock) + try repository.checkoutReference(localBranch.reference, options: checkoutOptions) + try repository.moveHEAD(to: localBranch.reference) + } else { + let remoteBranchName = "origin/\(branchName)" + let remoteBranch = try repository.lookUpBranch(withName: remoteBranchName, type: .remote, success: nil) + guard let remoteBranchOid = remoteBranch.oid else { + throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) + } + let localBranch = try repository.createBranchNamed(branchName, from: remoteBranchOid, message: nil) + try localBranch.updateTrackingBranch(remoteBranch) + let checkoutOptions = GTCheckoutOptions(strategy: .force, progressBlock: progressBlock) + try repository.checkoutReference(localBranch.reference, options: checkoutOptions) + try repository.moveHEAD(to: localBranch.reference) + } + } + + public func pull( + options: PullOptions, + transferProgressBlock: @escaping TransferProgressHandler + ) throws { + let remote = try GTRemote(name: "origin", in: repository) + try repository.pull(repository.currentBranch(), from: remote, withOptions: options, progress: transferProgressBlock) + } + + public func getRecentCommits(count: Int) throws -> [GTCommit] { + var commits = [GTCommit]() + let enumerator = try GTEnumerator(repository: repository) + if let targetOID = try repository.headReference().targetOID { + try enumerator.pushSHA(targetOID.sha) + } + for _ in 0 ..< count { + if let commit = try? enumerator.nextObject(withSuccess: nil) { + commits.append(commit) + } + } + return commits + } + + public func add(path: String) throws { + try repository.index().addFile(path) + try repository.index().write() + } + + public func rm(path: String) throws { + guard let repoURL = repository.fileURL else { + throw AppError.repositoryNotSet + } + + let url = repoURL.appendingPathComponent(path) + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + try repository.index().removeFile(path) + try repository.index().write() + } + + public func mv(from: String, to: String) throws { + guard let repoURL = repository.fileURL else { + throw AppError.repositoryNotSet + } + + let fromURL = repoURL.appendingPathComponent(from) + let toURL = repoURL.appendingPathComponent(to) + try FileManager.default.moveItem(at: fromURL, to: toURL) + try add(path: to) + try rm(path: from) + } + + public func commit(name: String, email: String, message: String) throws -> GTCommit { + guard let signature = GTSignature(name: name, email: email, time: Date()) else { + throw AppError.gitCreateSignature + } + return try commit(signature: signature, message: message) + } + + public func commit(signature: GTSignature, message: String) throws -> GTCommit { + let newTree = try repository.index().writeTree() + if repository.isHEADUnborn { + return try repository.createCommit(with: newTree, message: message, author: signature, committer: signature, parents: nil, updatingReferenceNamed: "HEAD") + } + let headReference = try repository.headReference() + let commitEnum = try GTEnumerator(repository: repository) + try commitEnum.pushSHA(headReference.targetOID!.sha) + guard let parent = commitEnum.nextObject() as? GTCommit else { + throw AppError.gitCommit + } + return try repository.createCommit(with: newTree, message: message, author: signature, committer: signature, parents: [parent], updatingReferenceNamed: headReference.name) + } + + public func push( + options: [String: Any], + transferProgressBlock: @escaping PushProgressHandler + ) throws { + let branch = try repository.currentBranch() + let remote = try GTRemote(name: "origin", in: repository) + try repository.push(branch, to: remote, withOptions: options, progress: transferProgressBlock) + } + + public func getLocalCommits() throws -> [GTCommit] { + let remoteBranchName = "origin/\(branchName)" + let remoteBranch = try repository.lookUpBranch(withName: remoteBranchName, type: .remote, success: nil) + return try repository.localCommitsRelative(toRemoteBranch: remoteBranch) + } + + public func numberOfCommits() -> Int { + Int(repository.numberOfCommits(inCurrentBranch: nil)) + } + + public func reset() throws { + let localCommits = try getLocalCommits() + if localCommits.isEmpty { + return + } + guard let firstLocalCommit = localCommits.last, + firstLocalCommit.parents.count == 1, + let newHead = firstLocalCommit.parents.first else { + throw AppError.gitReset + } + try repository.reset(to: newHead, resetType: .hard) + } + + public func lastCommitDate(path: String) throws -> Date { + let blameHunks = try repository.blame(withFile: path, options: nil).hunks + guard let latestCommitTime = blameHunks.map({ $0.finalSignature?.time?.timeIntervalSince1970 ?? 0 }).max() else { + return Date(timeIntervalSince1970: 0) + } + return Date(timeIntervalSince1970: latestCommitTime) + } +} diff --git a/passKit/Models/Password.swift b/passKit/Models/Password.swift index eb109ff..0b517dc 100644 --- a/passKit/Models/Password.swift +++ b/passKit/Models/Password.swift @@ -82,6 +82,10 @@ public class Password { initEverything() } + public func fileURL(in directoryURL: URL) -> URL { + directoryURL.appendingPathComponent(path) + } + public func updatePassword(name: String, path: String, plainText: String) { guard self.plainText != plainText || self.path != path else { return diff --git a/passKit/Models/PasswordEntity.swift b/passKit/Models/PasswordEntity.swift index ad59326..26dae9e 100644 --- a/passKit/Models/PasswordEntity.swift +++ b/passKit/Models/PasswordEntity.swift @@ -47,6 +47,10 @@ public final class PasswordEntity: NSManagedObject, Identifiable { getDirArray().joined(separator: " > ") } + public func fileURL(in directoryURL: URL) -> URL { + directoryURL.appendingPathComponent(path) + } + public func getDirArray() -> [String] { var parentEntity = parent var passwordCategoryArray: [String] = [] diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 4a9f219..84c1fcb 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -23,11 +23,8 @@ public class PasswordStore { }() public var storeURL: URL - public var tempStoreURL: URL { - URL(fileURLWithPath: "\(storeURL.path)-temp") - } - public var storeRepository: GTRepository? + public var gitRepository: GitRepository? public var gitSignatureForNow: GTSignature? { let gitSignatureName = Defaults.gitSignatureName ?? Globals.gitSignatureDefaultName @@ -54,6 +51,7 @@ public class PasswordStore { } private let fileManager = FileManager.default + private let notificationCenter = NotificationCenter.default private lazy var context: NSManagedObjectContext = PersistenceController.shared.viewContext() public var numberOfPasswords: Int { @@ -82,8 +80,8 @@ public class PasswordStore { return formatter.string(from: date) } - public var numberOfCommits: UInt? { - storeRepository?.numberOfCommits(inCurrentBranch: nil) + public var numberOfCommits: Int? { + gitRepository?.numberOfCommits() } init(url: URL = Globals.repositoryURL) { @@ -94,7 +92,7 @@ public class PasswordStore { do { if fileManager.fileExists(atPath: storeURL.path) { - try self.storeRepository = GTRepository(url: storeURL) + try self.gitRepository = GitRepository(with: storeURL) } } catch { print(error) @@ -126,31 +124,20 @@ public class PasswordStore { public func cloneRepository( remoteRepoURL: URL, branchName: String, - options: [AnyHashable: Any]? = nil, - transferProgressBlock: @escaping (UnsafePointer, UnsafeMutablePointer) -> Void = { _, _ in }, - checkoutProgressBlock: @escaping (String, UInt, UInt) -> Void = { _, _, _ in } + options: CloneOptions = [:], + transferProgressBlock: @escaping TransferProgressHandler = { _, _ in }, + checkoutProgressBlock: @escaping CheckoutProgressHandler = { _, _, _ in } ) throws { try? fileManager.removeItem(at: storeURL) - try? fileManager.removeItem(at: tempStoreURL) gitPassword = nil gitSSHPrivateKeyPassphrase = nil do { - storeRepository = try GTRepository.clone( - from: remoteRepoURL, - toWorkingDirectory: tempStoreURL, - options: options, - transferProgressBlock: transferProgressBlock - ) - try fileManager.moveItem(at: tempStoreURL, to: storeURL) - storeRepository = try GTRepository(url: storeURL) - if (try storeRepository?.currentBranch().name) != branchName { - try checkoutAndChangeBranch(withName: branchName, progressBlock: checkoutProgressBlock) - } + gitRepository = try GitRepository(from: remoteRepoURL, to: storeURL, branchName: branchName, options: options, transferProgressBlock: transferProgressBlock, checkoutProgressBlock: checkoutProgressBlock) } catch { Defaults.lastSyncedTime = nil DispatchQueue.main.async { self.deleteCoreData() - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) + self.notificationCenter.post(name: .passwordStoreUpdated, object: nil) } throw (error) } @@ -158,41 +145,24 @@ public class PasswordStore { DispatchQueue.main.async { self.deleteCoreData() self.initPasswordEntityCoreData() - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) + self.notificationCenter.post(name: .passwordStoreUpdated, object: nil) } } - private func checkoutAndChangeBranch(withName localBranchName: String, progressBlock: @escaping (String, UInt, UInt) -> Void) throws { - guard let storeRepository else { - throw AppError.repositoryNotSet - } - let remoteBranchName = "origin/\(localBranchName)" - let remoteBranch = try storeRepository.lookUpBranch(withName: remoteBranchName, type: .remote, success: nil) - guard let remoteBranchOid = remoteBranch.oid else { - throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) - } - let localBranch = try storeRepository.createBranchNamed(localBranchName, from: remoteBranchOid, message: nil) - try localBranch.updateTrackingBranch(remoteBranch) - let checkoutOptions = GTCheckoutOptions(strategy: .force, progressBlock: progressBlock) - try storeRepository.checkoutReference(localBranch.reference, options: checkoutOptions) - try storeRepository.moveHEAD(to: localBranch.reference) - } - public func pullRepository( - options: [String: Any], - progressBlock: @escaping (UnsafePointer, UnsafeMutablePointer) -> Void = { _, _ in } + options: PullOptions, + progressBlock: @escaping TransferProgressHandler = { _, _ in } ) throws { - guard let storeRepository else { + guard let gitRepository else { throw AppError.repositoryNotSet } - let remote = try GTRemote(name: "origin", in: storeRepository) - try storeRepository.pull(storeRepository.currentBranch(), from: remote, withOptions: options, progress: progressBlock) + try gitRepository.pull(options: options, transferProgressBlock: progressBlock) Defaults.lastSyncedTime = Date() setAllSynced() DispatchQueue.main.async { self.deleteCoreData() self.initPasswordEntityCoreData() - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) + self.notificationCenter.post(name: .passwordStoreUpdated, object: nil) } } @@ -202,20 +172,10 @@ public class PasswordStore { } public func getRecentCommits(count: Int) throws -> [GTCommit] { - guard let storeRepository else { - return [] + guard let gitRepository else { + throw AppError.repositoryNotSet } - var commits = [GTCommit]() - let enumerator = try GTEnumerator(repository: storeRepository) - if let targetOID = try storeRepository.headReference().targetOID { - try enumerator.pushSHA(targetOID.sha) - } - for _ in 0 ..< count { - if let commit = try? enumerator.nextObject(withSuccess: nil) { - commits.append(commit) - } - } - return commits + return try gitRepository.getRecentCommits(count: count) } public func fetchPasswordEntityCoreData(parent: PasswordEntity?) -> [PasswordEntity] { @@ -240,42 +200,18 @@ public class PasswordStore { } public func getLatestUpdateInfo(path: String) -> String { - guard let storeRepository else { + guard let gitRepository else { return "Unknown".localize() } - guard let blameHunks = try? storeRepository.blame(withFile: path, options: nil).hunks else { + guard let lastCommitDate = try? gitRepository.lastCommitDate(path: path) else { return "Unknown".localize() } - guard let latestCommitTime = blameHunks.map({ $0.finalSignature?.time?.timeIntervalSince1970 ?? 0 }).max() else { - return "Unknown".localize() - } - let lastCommitDate = Date(timeIntervalSince1970: latestCommitTime) if Date().timeIntervalSince(lastCommitDate) <= 60 { return "JustNow".localize() } return Self.dateFormatter.string(from: lastCommitDate) } - private func gitAdd(path: String) throws { - guard let storeRepository else { - throw AppError.repositoryNotSet - } - try storeRepository.index().addFile(path) - try storeRepository.index().write() - } - - private func gitRm(path: String) throws { - guard let storeRepository else { - throw AppError.repositoryNotSet - } - let url = storeURL.appendingPathComponent(path) - if fileManager.fileExists(atPath: url.path) { - try fileManager.removeItem(at: url) - } - try storeRepository.index().removeFile(path) - try storeRepository.index().write() - } - private func deleteDirectoryTree(at url: URL) throws { var tempURL = url.deletingLastPathComponent() while try fileManager.contentsOfDirectory(atPath: tempURL.path).isEmpty { @@ -289,52 +225,14 @@ public class PasswordStore { try fileManager.createDirectory(at: tempURL, withIntermediateDirectories: true) } - private func gitMv(from: String, to: String) throws { - let fromURL = storeURL.appendingPathComponent(from) - let toURL = storeURL.appendingPathComponent(to) - try fileManager.moveItem(at: fromURL, to: toURL) - try gitAdd(path: to) - try gitRm(path: from) - } - - private func gitCommit(message: String) throws -> GTCommit? { - guard let storeRepository else { - throw AppError.repositoryNotSet - } - let newTree = try storeRepository.index().writeTree() - let headReference = try storeRepository.headReference() - let commitEnum = try GTEnumerator(repository: storeRepository) - try commitEnum.pushSHA(headReference.targetOID!.sha) - let parent = commitEnum.nextObject() as! GTCommit - guard let signature = gitSignatureForNow else { - throw AppError.gitCreateSignature - } - return try storeRepository.createCommit(with: newTree, message: message, author: signature, committer: signature, parents: [parent], updatingReferenceNamed: headReference.name) - } - - private func getLocalBranch(withName branchName: String) throws -> GTBranch? { - guard let storeRepository else { - throw AppError.repositoryNotSet - } - let reference = GTBranch.localNamePrefix().appending(branchName) - let branches = try storeRepository.branches(withPrefix: reference) - return branches.first - } - public func pushRepository( - options: [String: Any], - transferProgressBlock: @escaping (UInt32, UInt32, Int, UnsafeMutablePointer) -> Void = { _, _, _, _ in } + options: PushOptions, + transferProgressBlock: @escaping PushProgressHandler = { _, _, _, _ in } ) throws { - guard let storeRepository else { + guard let gitRepository else { throw AppError.repositoryNotSet } - if let branch = try getLocalBranch(withName: Defaults.gitBranchName) { - let remote = try GTRemote(name: "origin", in: storeRepository) - try storeRepository.push(branch, to: remote, withOptions: options, progress: transferProgressBlock) - } - if numberOfLocalCommits != 0 { - throw AppError.gitPushNotSuccessful - } + try gitRepository.push(options: options, transferProgressBlock: transferProgressBlock) } private func addPasswordEntities(password: Password) throws -> PasswordEntity? { @@ -372,33 +270,34 @@ public class PasswordStore { } public func add(password: Password, keyID: String? = nil) throws -> PasswordEntity? { - let saveURL = storeURL.appendingPathComponent(password.path) + let saveURL = password.fileURL(in: storeURL) try createDirectoryTree(at: saveURL) try encrypt(password: password, keyID: keyID).write(to: saveURL) try gitAdd(path: password.path) - _ = try gitCommit(message: "AddPassword.".localize(password.path)) + try gitCommit(message: "AddPassword.".localize(password.path)) let newPasswordEntity = try addPasswordEntities(password: password) - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) + notificationCenter.post(name: .passwordStoreUpdated, object: nil) return newPasswordEntity } public func delete(passwordEntity: PasswordEntity) throws { - let deletedFileURL = storeURL.appendingPathComponent(passwordEntity.path) + let deletedFileURL = passwordEntity.fileURL(in: storeURL) + let deletedFilePath = passwordEntity.path try gitRm(path: passwordEntity.path) try deletePasswordEntities(passwordEntity: passwordEntity) try deleteDirectoryTree(at: deletedFileURL) - _ = try gitCommit(message: "RemovePassword.".localize(passwordEntity.path)) - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) + try gitCommit(message: "RemovePassword.".localize(deletedFilePath)) + notificationCenter.post(name: .passwordStoreUpdated, object: nil) } public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? { var newPasswordEntity: PasswordEntity? = passwordEntity - let url = storeURL.appendingPathComponent(passwordEntity.path) + let url = passwordEntity.fileURL(in: storeURL) if password.changed & PasswordChange.content.rawValue != 0 { try encrypt(password: password, keyID: keyID).write(to: url) try gitAdd(path: password.path) - _ = try gitCommit(message: "EditPassword.".localize(passwordEntity.path)) + try gitCommit(message: "EditPassword.".localize(passwordEntity.path)) newPasswordEntity = passwordEntity newPasswordEntity?.isSynced = false } @@ -406,7 +305,7 @@ public class PasswordStore { if password.changed & PasswordChange.path.rawValue != 0 { let deletedFileURL = url // add - let newFileURL = storeURL.appendingPathComponent(password.path) + let newFileURL = password.fileURL(in: storeURL) try createDirectoryTree(at: newFileURL) newPasswordEntity = try addPasswordEntities(password: password) @@ -415,11 +314,12 @@ public class PasswordStore { // delete try deleteDirectoryTree(at: deletedFileURL) + let deletedFilePath = passwordEntity.path try deletePasswordEntities(passwordEntity: passwordEntity) - _ = try gitCommit(message: "RenamePassword.".localize(passwordEntity.path, password.path)) + try gitCommit(message: "RenamePassword.".localize(deletedFilePath, password.path)) } saveUpdatedContext() - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) + notificationCenter.post(name: .passwordStoreUpdated, object: nil) return newPasswordEntity } @@ -440,17 +340,16 @@ public class PasswordStore { public func eraseStoreData() { // Delete files. try? fileManager.removeItem(at: storeURL) - try? fileManager.removeItem(at: tempStoreURL) // Delete core data. deleteCoreData() // Clean up variables inside PasswordStore. - storeRepository = nil + gitRepository = nil // Broadcast. - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) - NotificationCenter.default.post(name: .passwordStoreErased, object: nil) + notificationCenter.post(name: .passwordStoreUpdated, object: nil) + notificationCenter.post(name: .passwordStoreErased, object: nil) } public func erase() { @@ -469,50 +368,29 @@ public class PasswordStore { // return the number of discarded commits public func reset() throws -> Int { - guard let storeRepository else { + guard let gitRepository else { throw AppError.repositoryNotSet } - // get a list of local commits - let localCommits = try getLocalCommits() - if localCommits.isEmpty { - return 0 // no new commit - } - // get the oldest local commit - guard let firstLocalCommit = localCommits.last, - firstLocalCommit.parents.count == 1, - let newHead = firstLocalCommit.parents.first else { - throw AppError.gitReset - } - try storeRepository.reset(to: newHead, resetType: .hard) + let localCommitsCount = try getLocalCommits().count + try gitRepository.reset() setAllSynced() deleteCoreData() initPasswordEntityCoreData() - NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) - NotificationCenter.default.post(name: .passwordStoreChangeDiscarded, object: nil) - return localCommits.count + notificationCenter.post(name: .passwordStoreUpdated, object: nil) + notificationCenter.post(name: .passwordStoreChangeDiscarded, object: nil) + return localCommitsCount } private func getLocalCommits() throws -> [GTCommit] { - guard let storeRepository else { + guard let gitRepository else { throw AppError.repositoryNotSet } - // get the remote branch - let remoteBranchName = Defaults.gitBranchName - guard let remoteBranch = try storeRepository.remoteBranches().first(where: { $0.shortName == remoteBranchName }) else { - throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) - } - // check oid before calling localCommitsRelative - guard remoteBranch.oid != nil else { - throw AppError.repositoryRemoteBranchNotFound(branchName: remoteBranchName) - } - - // get a list of local commits - return try storeRepository.localCommitsRelative(toRemoteBranch: remoteBranch) + return try gitRepository.getLocalCommits() } public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { - let url = storeURL.appendingPathComponent(passwordEntity.path) + let url = passwordEntity.fileURL(in: storeURL) let encryptedData = try Data(contentsOf: url) let data: Data? = try { if Defaults.isEnableGPGIDOn { @@ -539,7 +417,7 @@ public class PasswordStore { } public func encrypt(password: Password, keyID: String? = nil) throws -> Data { - let encryptedDataPath = storeURL.appendingPathComponent(password.path) + let encryptedDataPath = password.fileURL(in: storeURL) let keyID = keyID ?? findGPGID(from: encryptedDataPath) if Defaults.isEnableGPGIDOn { return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) @@ -557,6 +435,37 @@ public class PasswordStore { } } +extension PasswordStore { + private func gitAdd(path: String) throws { + guard let gitRepository else { + throw AppError.repositoryNotSet + } + try gitRepository.add(path: path) + } + + private func gitRm(path: String) throws { + guard let gitRepository else { + throw AppError.repositoryNotSet + } + try gitRepository.rm(path: path) + } + + private func gitMv(from: String, to: String) throws { + guard let gitRepository else { + throw AppError.repositoryNotSet + } + try gitRepository.mv(from: from, to: to) + } + + @discardableResult + private func gitCommit(message: String) throws -> GTCommit { + guard let gitRepository, let gitSignatureForNow else { + throw AppError.repositoryNotSet + } + return try gitRepository.commit(signature: gitSignatureForNow, message: message) + } +} + func findGPGID(from url: URL) -> String { var path = url while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path), diff --git a/passKitTests/Models/GitRepositoryTest.swift b/passKitTests/Models/GitRepositoryTest.swift new file mode 100644 index 0000000..8135ddd --- /dev/null +++ b/passKitTests/Models/GitRepositoryTest.swift @@ -0,0 +1,141 @@ +// +// GitRepositoryTest.swift +// pass +// +// Created by Mingshen Sun on 1/26/25. +// Copyright © 2025 Bob Sun. All rights reserved. +// + +import ObjectiveGit +import XCTest +@testable import passKit + +final class GitRepositoryTest: XCTestCase { + private var bareRepositoryURL: URL! + private var workingRepositoryURL: URL! + private var repository: GitRepository! + private let fileManager = FileManager.default + private let checkoutProgressBlock: CheckoutProgressHandler = { _, _, _ in + } + + private let transferProgressBlock: TransferProgressHandler = { _, _ in + } + + private let pushProgressBlock: PushProgressHandler = { _, _, _, _ in + } + + override func setUpWithError() throws { + try super.setUpWithError() + bareRepositoryURL = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + workingRepositoryURL = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try? fileManager.createDirectory( + at: bareRepositoryURL, + withIntermediateDirectories: true + ) + + let options = [ + GTRepositoryInitOptionsFlags: GTRepositoryInitFlags.bare.rawValue, + ] + try GTRepository.initializeEmpty(atFileURL: bareRepositoryURL, options: options) + + repository = try GitRepository(from: bareRepositoryURL, to: workingRepositoryURL, branchName: "master", options: options, transferProgressBlock: transferProgressBlock, checkoutProgressBlock: checkoutProgressBlock) + } + + func testSetup() { + let dotGitFileURL = workingRepositoryURL.appendingPathComponent(".git") + XCTAssertTrue(fileManager.fileExists(atPath: dotGitFileURL.path)) + } + + func testCommitHeadUnborn() throws { + _ = try repository.commit(name: "name", email: "email@email.com", message: "message") + } + + func testCommit() throws { + try ["file1", "file2"].forEach { filename in + let fileURL = workingRepositoryURL.appendingPathComponent(filename) + try "change1".write(toFile: fileURL.path, atomically: true, encoding: .utf8) + try repository.add(path: filename) + _ = try repository.commit(name: "name", email: "email@email.com", message: "message: \(filename)") + } + } + + func testPush() throws { + try testCommit() + let options: [String: Any] = [:] + try repository.push(options: options, transferProgressBlock: pushProgressBlock) + } + + func testGetRecentCommits() throws { + _ = try repository.commit(name: "name", email: "email@email.com", message: "message1") + let commit = try repository.getRecentCommits(count: 1) + XCTAssertEqual(commit.first?.message, "message1") + } + + func testGetLocalCommits() throws { + try ["file1", "file2"].forEach { filename in + let fileURL = workingRepositoryURL.appendingPathComponent(filename) + try "change".write(toFile: fileURL.path, atomically: true, encoding: .utf8) + try repository.add(path: filename) + _ = try repository.commit(name: "name", email: "email@email.com", message: "message: \(filename)") + } + let options: [String: Any] = [:] + try repository.push(options: options, transferProgressBlock: pushProgressBlock) + try ["file3", "file4"].forEach { filename in + let fileURL = workingRepositoryURL.appendingPathComponent(filename) + try "change".write(toFile: fileURL.path, atomically: true, encoding: .utf8) + try repository.add(path: filename) + _ = try repository.commit(name: "name", email: "email@email.com", message: "message: \(filename)") + } + let commit = try repository.getLocalCommits() + XCTAssertEqual(commit.first?.message, "message: file4") + } + + func testCheckoutAndChangeBranch() throws { + _ = try repository.commit(name: "name", email: "email@email.com", message: "message") + let repo = repository.repository + let branchName = "feature-branch" + let head = try repo.headReference() + let branch = try repo.createBranchNamed(branchName, from: head.targetOID!, message: nil) + let remote = try GTRemote(name: "origin", in: repo) + try repo.pushBranches([branch], to: remote) + + try repository.checkoutAndChangeBranch(branchName: "feature-branch", progressBlock: checkoutProgressBlock) + } + + func testRm() throws { + try ["file1", "file2"].forEach { filename in + let fileURL = workingRepositoryURL.appendingPathComponent(filename) + try "change1".write(toFile: fileURL.path, atomically: true, encoding: .utf8) + try repository.add(path: filename) + _ = try repository.commit(name: "name", email: "email@email.com", message: "message: add \(filename)") + } + + try repository.rm(path: "file1") + let commit = try repository.commit(name: "name", email: "email@email.com", message: "message: remove file1") + XCTAssertEqual(commit.message, "message: remove file1") + XCTAssertFalse(fileManager.fileExists(atPath: workingRepositoryURL.appendingPathComponent("file1").path)) + } + + func testMv() throws { + try ["file1", "file2"].forEach { filename in + let fileURL = workingRepositoryURL.appendingPathComponent(filename) + try "change1".write(toFile: fileURL.path, atomically: true, encoding: .utf8) + try repository.add(path: filename) + _ = try repository.commit(name: "name", email: "email@email.com", message: "message: add \(filename)") + } + + try repository.mv(from: "file1", to: "file3") + let commit = try repository.commit(name: "name", email: "email@email.com", message: "message: remove file1") + XCTAssertEqual(commit.message, "message: remove file1") + XCTAssertFalse(fileManager.fileExists(atPath: workingRepositoryURL.appendingPathComponent("file1").path)) + XCTAssertTrue(fileManager.fileExists(atPath: workingRepositoryURL.appendingPathComponent("file3").path)) + } + + override func tearDownWithError() throws { + try fileManager.removeItem(at: bareRepositoryURL) + try fileManager.removeItem(at: workingRepositoryURL) + super.tearDown() + } +} diff --git a/passKitTests/Models/PasswordTest.swift b/passKitTests/Models/PasswordTest.swift index 9f4d203..8da784d 100644 --- a/passKitTests/Models/PasswordTest.swift +++ b/passKitTests/Models/PasswordTest.swift @@ -247,12 +247,6 @@ final class PasswordTest: XCTestCase { XCTAssertEqual(password.nameFromPath, "exampleusername") } - func testDotInFilename() { - let password = getPasswordObjectWith(content: "", path: "exampleservice/..pgp") - - XCTAssertEqual(password.nameFromPath, ".") - } - func testMultilineValues() { let lineBreakField = "with line breaks" => "|\n This is \n text spread over \n multiple lines! " let noLineBreakField = "without line breaks" => " > \n This is \n text spread over\n multiple lines!"