Refactor git repository model (#674)

- Create a new model class for operations of Git repository.
- Refactor everything related with git.
- Add unit tests for git functions.
This commit is contained in:
Mingshen Sun 2025-02-02 22:18:16 -08:00 committed by GitHub
parent 5a1458e196
commit a69c4d16b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 421 additions and 181 deletions

View file

@ -198,6 +198,8 @@
DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; }; DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; };
DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; }; DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; };
DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.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 */; }; DC7CBBBD2D0FA3F2003BB4D2 /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = DC7CBBBC2D0FA3F2003BB4D2 /* YubiKit */; };
DC7CBBBF2D0FAC92003BB4D2 /* YKFSmartCardInterfaceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */; }; DC7CBBBF2D0FAC92003BB4D2 /* YKFSmartCardInterfaceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */; };
DC8963C01E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.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 = "<group>"; }; DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = "<group>"; };
DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = "<group>"; }; DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = "<group>"; };
DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = "<group>"; }; DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = "<group>"; };
DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = "<group>"; };
DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepositoryTest.swift; sourceTree = "<group>"; };
DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YKFSmartCardInterfaceExtension.swift; sourceTree = "<group>"; }; DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YKFSmartCardInterfaceExtension.swift; sourceTree = "<group>"; };
DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHKeyURLImportTableViewController.swift; sourceTree = "<group>"; }; DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHKeyURLImportTableViewController.swift; sourceTree = "<group>"; };
DC917BD31E2E8231000FDF54 /* Pass.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Pass.app; sourceTree = BUILT_PRODUCTS_DIR; }; DC917BD31E2E8231000FDF54 /* Pass.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Pass.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -737,6 +741,7 @@
30C015A7214ED378005BB6DF /* Models */ = { 30C015A7214ED378005BB6DF /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */,
30695E2424FAEF2600C9D46E /* GitCredentialTest.swift */, 30695E2424FAEF2600C9D46E /* GitCredentialTest.swift */,
9ADC954024418A5F0005402E /* PasswordStoreTest.swift */, 9ADC954024418A5F0005402E /* PasswordStoreTest.swift */,
A2699ACE24027D9500F36323 /* PasswordTableEntryTest.swift */, A2699ACE24027D9500F36323 /* PasswordTableEntryTest.swift */,
@ -916,6 +921,7 @@
A2F4E20E1EED7F040011986E /* Models */ = { A2F4E20E1EED7F040011986E /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DC64745E2D45B23A004B4BBC /* GitRepository.swift */,
30697C4121F63CAB0064FCAC /* GitCredential.swift */, 30697C4121F63CAB0064FCAC /* GitCredential.swift */,
30697C4221F63CAB0064FCAC /* PasscodeLock.swift */, 30697C4221F63CAB0064FCAC /* PasscodeLock.swift */,
30697C4021F63CAB0064FCAC /* Password.swift */, 30697C4021F63CAB0064FCAC /* Password.swift */,
@ -1599,6 +1605,7 @@
30697C2A21F63C5A0064FCAC /* NotificationNames.swift in Sources */, 30697C2A21F63C5A0064FCAC /* NotificationNames.swift in Sources */,
30CCA91623258C380048CA51 /* PGPInterface.swift in Sources */, 30CCA91623258C380048CA51 /* PGPInterface.swift in Sources */,
30DAFD4A240985A7002456E7 /* Array+Slices.swift in Sources */, 30DAFD4A240985A7002456E7 /* Array+Slices.swift in Sources */,
DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */,
9A74D2E0277D2F8C00F7BC44 /* UIAlertControllerExtension.swift in Sources */, 9A74D2E0277D2F8C00F7BC44 /* UIAlertControllerExtension.swift in Sources */,
30697C4721F63CAB0064FCAC /* PasscodeLock.swift in Sources */, 30697C4721F63CAB0064FCAC /* PasscodeLock.swift in Sources */,
A2699ACD2402631400F36323 /* PasswordTableEntry.swift in Sources */, A2699ACD2402631400F36323 /* PasswordTableEntry.swift in Sources */,
@ -1628,6 +1635,7 @@
301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */,
9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */,
30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */,
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */,
A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */,
30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */,
A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */,

View file

@ -593,7 +593,7 @@ extension PasswordDetailTableViewController {
handleError(error: AppError.other(message: "PasswordDoesNotExist")) handleError(error: AppError.other(message: "PasswordDoesNotExist"))
return 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 { guard let encryptedData = try? Data(contentsOf: encryptedDataPath) else {
handleError(error: AppError.other(message: "PasswordDoesNotExist")) handleError(error: AppError.other(message: "PasswordDoesNotExist"))

View file

@ -348,7 +348,7 @@ extension PasswordNavigationViewController {
return false return false
} }
} else if identifier == "addPasswordSegue" { } 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) Utils.alert(title: "CannotAddPassword".localize(), message: "MakeSurePgpAndGitProperlySet.".localize(), controller: self)
return false return false
} }

View file

@ -16,6 +16,7 @@ public enum AppError: Error, Equatable {
case readingFile(fileName: String) case readingFile(fileName: String)
case passwordDuplicated case passwordDuplicated
case gitReset case gitReset
case gitCommit
case gitCreateSignature case gitCreateSignature
case gitPushNotSuccessful case gitPushNotSuccessful
case pgpPublicKeyNotFound(keyID: String) case pgpPublicKeyNotFound(keyID: String)

View file

@ -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<git_transfer_progress>, UnsafeMutablePointer<ObjCBool>) -> Void
public typealias CheckoutProgressHandler = (String, UInt, UInt) -> Void
public typealias PushProgressHandler = (UInt32, UInt32, Int, UnsafeMutablePointer<ObjCBool>) -> 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)
}
}

View file

@ -82,6 +82,10 @@ public class Password {
initEverything() initEverything()
} }
public func fileURL(in directoryURL: URL) -> URL {
directoryURL.appendingPathComponent(path)
}
public func updatePassword(name: String, path: String, plainText: String) { public func updatePassword(name: String, path: String, plainText: String) {
guard self.plainText != plainText || self.path != path else { guard self.plainText != plainText || self.path != path else {
return return

View file

@ -47,6 +47,10 @@ public final class PasswordEntity: NSManagedObject, Identifiable {
getDirArray().joined(separator: " > ") getDirArray().joined(separator: " > ")
} }
public func fileURL(in directoryURL: URL) -> URL {
directoryURL.appendingPathComponent(path)
}
public func getDirArray() -> [String] { public func getDirArray() -> [String] {
var parentEntity = parent var parentEntity = parent
var passwordCategoryArray: [String] = [] var passwordCategoryArray: [String] = []

View file

@ -23,11 +23,8 @@ public class PasswordStore {
}() }()
public var storeURL: URL 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? { public var gitSignatureForNow: GTSignature? {
let gitSignatureName = Defaults.gitSignatureName ?? Globals.gitSignatureDefaultName let gitSignatureName = Defaults.gitSignatureName ?? Globals.gitSignatureDefaultName
@ -54,6 +51,7 @@ public class PasswordStore {
} }
private let fileManager = FileManager.default private let fileManager = FileManager.default
private let notificationCenter = NotificationCenter.default
private lazy var context: NSManagedObjectContext = PersistenceController.shared.viewContext() private lazy var context: NSManagedObjectContext = PersistenceController.shared.viewContext()
public var numberOfPasswords: Int { public var numberOfPasswords: Int {
@ -82,8 +80,8 @@ public class PasswordStore {
return formatter.string(from: date) return formatter.string(from: date)
} }
public var numberOfCommits: UInt? { public var numberOfCommits: Int? {
storeRepository?.numberOfCommits(inCurrentBranch: nil) gitRepository?.numberOfCommits()
} }
init(url: URL = Globals.repositoryURL) { init(url: URL = Globals.repositoryURL) {
@ -94,7 +92,7 @@ public class PasswordStore {
do { do {
if fileManager.fileExists(atPath: storeURL.path) { if fileManager.fileExists(atPath: storeURL.path) {
try self.storeRepository = GTRepository(url: storeURL) try self.gitRepository = GitRepository(with: storeURL)
} }
} catch { } catch {
print(error) print(error)
@ -126,31 +124,20 @@ public class PasswordStore {
public func cloneRepository( public func cloneRepository(
remoteRepoURL: URL, remoteRepoURL: URL,
branchName: String, branchName: String,
options: [AnyHashable: Any]? = nil, options: CloneOptions = [:],
transferProgressBlock: @escaping (UnsafePointer<git_transfer_progress>, UnsafeMutablePointer<ObjCBool>) -> Void = { _, _ in }, transferProgressBlock: @escaping TransferProgressHandler = { _, _ in },
checkoutProgressBlock: @escaping (String, UInt, UInt) -> Void = { _, _, _ in } checkoutProgressBlock: @escaping CheckoutProgressHandler = { _, _, _ in }
) throws { ) throws {
try? fileManager.removeItem(at: storeURL) try? fileManager.removeItem(at: storeURL)
try? fileManager.removeItem(at: tempStoreURL)
gitPassword = nil gitPassword = nil
gitSSHPrivateKeyPassphrase = nil gitSSHPrivateKeyPassphrase = nil
do { do {
storeRepository = try GTRepository.clone( gitRepository = try GitRepository(from: remoteRepoURL, to: storeURL, branchName: branchName, options: options, transferProgressBlock: transferProgressBlock, checkoutProgressBlock: checkoutProgressBlock)
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)
}
} catch { } catch {
Defaults.lastSyncedTime = nil Defaults.lastSyncedTime = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.deleteCoreData() self.deleteCoreData()
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) self.notificationCenter.post(name: .passwordStoreUpdated, object: nil)
} }
throw (error) throw (error)
} }
@ -158,41 +145,24 @@ public class PasswordStore {
DispatchQueue.main.async { DispatchQueue.main.async {
self.deleteCoreData() self.deleteCoreData()
self.initPasswordEntityCoreData() 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( public func pullRepository(
options: [String: Any], options: PullOptions,
progressBlock: @escaping (UnsafePointer<git_transfer_progress>, UnsafeMutablePointer<ObjCBool>) -> Void = { _, _ in } progressBlock: @escaping TransferProgressHandler = { _, _ in }
) throws { ) throws {
guard let storeRepository else { guard let gitRepository else {
throw AppError.repositoryNotSet throw AppError.repositoryNotSet
} }
let remote = try GTRemote(name: "origin", in: storeRepository) try gitRepository.pull(options: options, transferProgressBlock: progressBlock)
try storeRepository.pull(storeRepository.currentBranch(), from: remote, withOptions: options, progress: progressBlock)
Defaults.lastSyncedTime = Date() Defaults.lastSyncedTime = Date()
setAllSynced() setAllSynced()
DispatchQueue.main.async { DispatchQueue.main.async {
self.deleteCoreData() self.deleteCoreData()
self.initPasswordEntityCoreData() 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] { public func getRecentCommits(count: Int) throws -> [GTCommit] {
guard let storeRepository else { guard let gitRepository else {
return [] throw AppError.repositoryNotSet
} }
var commits = [GTCommit]() return try gitRepository.getRecentCommits(count: count)
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
} }
public func fetchPasswordEntityCoreData(parent: PasswordEntity?) -> [PasswordEntity] { public func fetchPasswordEntityCoreData(parent: PasswordEntity?) -> [PasswordEntity] {
@ -240,42 +200,18 @@ public class PasswordStore {
} }
public func getLatestUpdateInfo(path: String) -> String { public func getLatestUpdateInfo(path: String) -> String {
guard let storeRepository else { guard let gitRepository else {
return "Unknown".localize() 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() 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 { if Date().timeIntervalSince(lastCommitDate) <= 60 {
return "JustNow".localize() return "JustNow".localize()
} }
return Self.dateFormatter.string(from: lastCommitDate) 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 { private func deleteDirectoryTree(at url: URL) throws {
var tempURL = url.deletingLastPathComponent() var tempURL = url.deletingLastPathComponent()
while try fileManager.contentsOfDirectory(atPath: tempURL.path).isEmpty { while try fileManager.contentsOfDirectory(atPath: tempURL.path).isEmpty {
@ -289,52 +225,14 @@ public class PasswordStore {
try fileManager.createDirectory(at: tempURL, withIntermediateDirectories: true) 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( public func pushRepository(
options: [String: Any], options: PushOptions,
transferProgressBlock: @escaping (UInt32, UInt32, Int, UnsafeMutablePointer<ObjCBool>) -> Void = { _, _, _, _ in } transferProgressBlock: @escaping PushProgressHandler = { _, _, _, _ in }
) throws { ) throws {
guard let storeRepository else { guard let gitRepository else {
throw AppError.repositoryNotSet throw AppError.repositoryNotSet
} }
if let branch = try getLocalBranch(withName: Defaults.gitBranchName) { try gitRepository.push(options: options, transferProgressBlock: transferProgressBlock)
let remote = try GTRemote(name: "origin", in: storeRepository)
try storeRepository.push(branch, to: remote, withOptions: options, progress: transferProgressBlock)
}
if numberOfLocalCommits != 0 {
throw AppError.gitPushNotSuccessful
}
} }
private func addPasswordEntities(password: Password) throws -> PasswordEntity? { private func addPasswordEntities(password: Password) throws -> PasswordEntity? {
@ -372,33 +270,34 @@ public class PasswordStore {
} }
public func add(password: Password, keyID: String? = nil) throws -> PasswordEntity? { 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 createDirectoryTree(at: saveURL)
try encrypt(password: password, keyID: keyID).write(to: saveURL) try encrypt(password: password, keyID: keyID).write(to: saveURL)
try gitAdd(path: password.path) 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) let newPasswordEntity = try addPasswordEntities(password: password)
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) notificationCenter.post(name: .passwordStoreUpdated, object: nil)
return newPasswordEntity return newPasswordEntity
} }
public func delete(passwordEntity: PasswordEntity) throws { 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 gitRm(path: passwordEntity.path)
try deletePasswordEntities(passwordEntity: passwordEntity) try deletePasswordEntities(passwordEntity: passwordEntity)
try deleteDirectoryTree(at: deletedFileURL) try deleteDirectoryTree(at: deletedFileURL)
_ = try gitCommit(message: "RemovePassword.".localize(passwordEntity.path)) try gitCommit(message: "RemovePassword.".localize(deletedFilePath))
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) notificationCenter.post(name: .passwordStoreUpdated, object: nil)
} }
public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? { public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? {
var newPasswordEntity: PasswordEntity? = passwordEntity var newPasswordEntity: PasswordEntity? = passwordEntity
let url = storeURL.appendingPathComponent(passwordEntity.path) let url = passwordEntity.fileURL(in: storeURL)
if password.changed & PasswordChange.content.rawValue != 0 { if password.changed & PasswordChange.content.rawValue != 0 {
try encrypt(password: password, keyID: keyID).write(to: url) try encrypt(password: password, keyID: keyID).write(to: url)
try gitAdd(path: password.path) try gitAdd(path: password.path)
_ = try gitCommit(message: "EditPassword.".localize(passwordEntity.path)) try gitCommit(message: "EditPassword.".localize(passwordEntity.path))
newPasswordEntity = passwordEntity newPasswordEntity = passwordEntity
newPasswordEntity?.isSynced = false newPasswordEntity?.isSynced = false
} }
@ -406,7 +305,7 @@ public class PasswordStore {
if password.changed & PasswordChange.path.rawValue != 0 { if password.changed & PasswordChange.path.rawValue != 0 {
let deletedFileURL = url let deletedFileURL = url
// add // add
let newFileURL = storeURL.appendingPathComponent(password.path) let newFileURL = password.fileURL(in: storeURL)
try createDirectoryTree(at: newFileURL) try createDirectoryTree(at: newFileURL)
newPasswordEntity = try addPasswordEntities(password: password) newPasswordEntity = try addPasswordEntities(password: password)
@ -415,11 +314,12 @@ public class PasswordStore {
// delete // delete
try deleteDirectoryTree(at: deletedFileURL) try deleteDirectoryTree(at: deletedFileURL)
let deletedFilePath = passwordEntity.path
try deletePasswordEntities(passwordEntity: passwordEntity) try deletePasswordEntities(passwordEntity: passwordEntity)
_ = try gitCommit(message: "RenamePassword.".localize(passwordEntity.path, password.path)) try gitCommit(message: "RenamePassword.".localize(deletedFilePath, password.path))
} }
saveUpdatedContext() saveUpdatedContext()
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) notificationCenter.post(name: .passwordStoreUpdated, object: nil)
return newPasswordEntity return newPasswordEntity
} }
@ -440,17 +340,16 @@ public class PasswordStore {
public func eraseStoreData() { public func eraseStoreData() {
// Delete files. // Delete files.
try? fileManager.removeItem(at: storeURL) try? fileManager.removeItem(at: storeURL)
try? fileManager.removeItem(at: tempStoreURL)
// Delete core data. // Delete core data.
deleteCoreData() deleteCoreData()
// Clean up variables inside PasswordStore. // Clean up variables inside PasswordStore.
storeRepository = nil gitRepository = nil
// Broadcast. // Broadcast.
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) notificationCenter.post(name: .passwordStoreUpdated, object: nil)
NotificationCenter.default.post(name: .passwordStoreErased, object: nil) notificationCenter.post(name: .passwordStoreErased, object: nil)
} }
public func erase() { public func erase() {
@ -469,50 +368,29 @@ public class PasswordStore {
// return the number of discarded commits // return the number of discarded commits
public func reset() throws -> Int { public func reset() throws -> Int {
guard let storeRepository else { guard let gitRepository else {
throw AppError.repositoryNotSet throw AppError.repositoryNotSet
} }
// get a list of local commits let localCommitsCount = try getLocalCommits().count
let localCommits = try getLocalCommits() try gitRepository.reset()
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)
setAllSynced() setAllSynced()
deleteCoreData() deleteCoreData()
initPasswordEntityCoreData() initPasswordEntityCoreData()
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) notificationCenter.post(name: .passwordStoreUpdated, object: nil)
NotificationCenter.default.post(name: .passwordStoreChangeDiscarded, object: nil) notificationCenter.post(name: .passwordStoreChangeDiscarded, object: nil)
return localCommits.count return localCommitsCount
} }
private func getLocalCommits() throws -> [GTCommit] { private func getLocalCommits() throws -> [GTCommit] {
guard let storeRepository else { guard let gitRepository else {
throw AppError.repositoryNotSet throw AppError.repositoryNotSet
} }
// get the remote branch return try gitRepository.getLocalCommits()
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)
} }
public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { 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 encryptedData = try Data(contentsOf: url)
let data: Data? = try { let data: Data? = try {
if Defaults.isEnableGPGIDOn { if Defaults.isEnableGPGIDOn {
@ -539,7 +417,7 @@ public class PasswordStore {
} }
public func encrypt(password: Password, keyID: String? = nil) throws -> Data { 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) let keyID = keyID ?? findGPGID(from: encryptedDataPath)
if Defaults.isEnableGPGIDOn { if Defaults.isEnableGPGIDOn {
return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) 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 { func findGPGID(from url: URL) -> String {
var path = url var path = url
while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path), while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path),

View file

@ -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()
}
}

View file

@ -247,12 +247,6 @@ final class PasswordTest: XCTestCase {
XCTAssertEqual(password.nameFromPath, "exampleusername") XCTAssertEqual(password.nameFromPath, "exampleusername")
} }
func testDotInFilename() {
let password = getPasswordObjectWith(content: "", path: "exampleservice/..pgp")
XCTAssertEqual(password.nameFromPath, ".")
}
func testMultilineValues() { func testMultilineValues() {
let lineBreakField = "with line breaks" => "|\n This is \n text spread over \n multiple lines! " 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!" let noLineBreakField = "without line breaks" => " > \n This is \n text spread over\n multiple lines!"