Skip to content

Commit a69c4d1

Browse files
authored
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.
1 parent 5a1458e commit a69c4d1

File tree

10 files changed

+421
-181
lines changed

10 files changed

+421
-181
lines changed

pass.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@
198198
DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; };
199199
DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; };
200200
DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; };
201+
DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; };
202+
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */; };
201203
DC7CBBBD2D0FA3F2003BB4D2 /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = DC7CBBBC2D0FA3F2003BB4D2 /* YubiKit */; };
202204
DC7CBBBF2D0FAC92003BB4D2 /* YKFSmartCardInterfaceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */; };
203205
DC8963C01E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift */; };
@@ -499,6 +501,8 @@
499501
DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = "<group>"; };
500502
DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = "<group>"; };
501503
DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = "<group>"; };
504+
DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = "<group>"; };
505+
DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepositoryTest.swift; sourceTree = "<group>"; };
502506
DC7CBBBE2D0FAC8E003BB4D2 /* YKFSmartCardInterfaceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YKFSmartCardInterfaceExtension.swift; sourceTree = "<group>"; };
503507
DC8963BF1E38EEB900828B09 /* SSHKeyURLImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSHKeyURLImportTableViewController.swift; sourceTree = "<group>"; };
504508
DC917BD31E2E8231000FDF54 /* Pass.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Pass.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -737,6 +741,7 @@
737741
30C015A7214ED378005BB6DF /* Models */ = {
738742
isa = PBXGroup;
739743
children = (
744+
DC6474602D46A8F2004B4BBC /* GitRepositoryTest.swift */,
740745
30695E2424FAEF2600C9D46E /* GitCredentialTest.swift */,
741746
9ADC954024418A5F0005402E /* PasswordStoreTest.swift */,
742747
A2699ACE24027D9500F36323 /* PasswordTableEntryTest.swift */,
@@ -916,6 +921,7 @@
916921
A2F4E20E1EED7F040011986E /* Models */ = {
917922
isa = PBXGroup;
918923
children = (
924+
DC64745E2D45B23A004B4BBC /* GitRepository.swift */,
919925
30697C4121F63CAB0064FCAC /* GitCredential.swift */,
920926
30697C4221F63CAB0064FCAC /* PasscodeLock.swift */,
921927
30697C4021F63CAB0064FCAC /* Password.swift */,
@@ -1599,6 +1605,7 @@
15991605
30697C2A21F63C5A0064FCAC /* NotificationNames.swift in Sources */,
16001606
30CCA91623258C380048CA51 /* PGPInterface.swift in Sources */,
16011607
30DAFD4A240985A7002456E7 /* Array+Slices.swift in Sources */,
1608+
DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */,
16021609
9A74D2E0277D2F8C00F7BC44 /* UIAlertControllerExtension.swift in Sources */,
16031610
30697C4721F63CAB0064FCAC /* PasscodeLock.swift in Sources */,
16041611
A2699ACD2402631400F36323 /* PasswordTableEntry.swift in Sources */,
@@ -1628,6 +1635,7 @@
16281635
301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */,
16291636
9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */,
16301637
30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */,
1638+
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */,
16311639
A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */,
16321640
30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */,
16331641
A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */,

pass/Controllers/PasswordDetailTableViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,7 @@ extension PasswordDetailTableViewController {
593593
handleError(error: AppError.other(message: "PasswordDoesNotExist"))
594594
return
595595
}
596-
let encryptedDataPath = PasswordStore.shared.storeURL.appendingPathComponent(passwordEntity.path)
596+
let encryptedDataPath = passwordEntity.fileURL(in: PasswordStore.shared.storeURL)
597597

598598
guard let encryptedData = try? Data(contentsOf: encryptedDataPath) else {
599599
handleError(error: AppError.other(message: "PasswordDoesNotExist"))

pass/Controllers/PasswordNavigationViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ extension PasswordNavigationViewController {
348348
return false
349349
}
350350
} else if identifier == "addPasswordSegue" {
351-
guard PGPAgent.shared.isPrepared, PasswordStore.shared.storeRepository != nil else {
351+
guard PGPAgent.shared.isPrepared, PasswordStore.shared.gitRepository != nil else {
352352
Utils.alert(title: "CannotAddPassword".localize(), message: "MakeSurePgpAndGitProperlySet.".localize(), controller: self)
353353
return false
354354
}

passKit/Helpers/AppError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public enum AppError: Error, Equatable {
1616
case readingFile(fileName: String)
1717
case passwordDuplicated
1818
case gitReset
19+
case gitCommit
1920
case gitCreateSignature
2021
case gitPushNotSuccessful
2122
case pgpPublicKeyNotFound(keyID: String)

passKit/Models/GitRepository.swift

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
}

passKit/Models/Password.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ public class Password {
8282
initEverything()
8383
}
8484

85+
public func fileURL(in directoryURL: URL) -> URL {
86+
directoryURL.appendingPathComponent(path)
87+
}
88+
8589
public func updatePassword(name: String, path: String, plainText: String) {
8690
guard self.plainText != plainText || self.path != path else {
8791
return

passKit/Models/PasswordEntity.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ public final class PasswordEntity: NSManagedObject, Identifiable {
4747
getDirArray().joined(separator: " > ")
4848
}
4949

50+
public func fileURL(in directoryURL: URL) -> URL {
51+
directoryURL.appendingPathComponent(path)
52+
}
53+
5054
public func getDirArray() -> [String] {
5155
var parentEntity = parent
5256
var passwordCategoryArray: [String] = []

0 commit comments

Comments
 (0)