Skip to content

Commit 9da95e9

Browse files
committed
add git pull.
1 parent fd90098 commit 9da95e9

4 files changed

Lines changed: 374 additions & 0 deletions

File tree

Sources/SwiftGitX/Helpers/SwiftGitXError.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,9 @@ extension SwiftGitXError {
423423
public static let fetch = Operation(rawValue: "fetch")
424424
public static let head = Operation(rawValue: "head")
425425
public static let index = Operation(rawValue: "index")
426+
public static let merge = Operation(rawValue: "merge")
426427
public static let patch = Operation(rawValue: "patch")
428+
public static let pull = Operation(rawValue: "pull")
427429
public static let push = Operation(rawValue: "push")
428430
public static let reset = Operation(rawValue: "reset")
429431
public static let restore = Operation(rawValue: "restore")
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
//
2+
// Repository+pull.swift
3+
// SwiftGitX
4+
//
5+
// Created by dunkbing
6+
//
7+
8+
import libgit2
9+
10+
extension Repository {
11+
/// Pull changes from the remote repository.
12+
///
13+
/// - Parameter remote: The remote to pull the changes from.
14+
///
15+
/// This method fetches changes from the remote and merges them into the current branch.
16+
/// It supports fast-forward merges and normal merges.
17+
///
18+
/// If the remote is not specified, the upstream of the current branch is used
19+
/// and if the upstream branch is not found, the `origin` remote is used.
20+
///
21+
/// - Throws: `SwiftGitXError` if the pull operation fails or if there are conflicts.
22+
///
23+
/// ### Example
24+
/// ```swift
25+
/// // Pull from the default remote
26+
/// try await repository.pull()
27+
///
28+
/// // Pull from a specific remote
29+
/// let remote = repository.remote["origin"]!
30+
/// try await repository.pull(remote: remote)
31+
/// ```
32+
public nonisolated func pull(remote: Remote? = nil) async throws(SwiftGitXError) {
33+
// Get the current branch
34+
let currentBranch = try branch.current
35+
36+
// Get the remote
37+
guard let remote = remote ?? currentBranch.remote ?? self.remote["origin"] else {
38+
throw SwiftGitXError(code: .notFound, operation: .pull, category: .reference, message: "Remote not found")
39+
}
40+
41+
// Get the upstream branch name
42+
guard let upstream = currentBranch.upstream else {
43+
throw SwiftGitXError(
44+
code: .notFound, operation: .pull, category: .reference,
45+
message: "No upstream branch configured for '\(currentBranch.name)'"
46+
)
47+
}
48+
49+
// Fetch from remote first
50+
try await fetch(remote: remote)
51+
52+
// Get the remote branch after fetch
53+
let remoteBranch = try branch.get(named: upstream.name, type: .remote)
54+
55+
// Get the commit to merge
56+
guard let remoteCommit = remoteBranch.target as? Commit else {
57+
throw SwiftGitXError(
58+
code: .error, operation: .pull, category: .reference,
59+
message: "Remote branch does not point to a commit"
60+
)
61+
}
62+
63+
// Perform merge analysis
64+
var analysis = git_merge_analysis_t(rawValue: 0)
65+
var preference = git_merge_preference_t(rawValue: 0)
66+
67+
var remoteOID = remoteCommit.id.raw
68+
var annotatedCommit: OpaquePointer?
69+
70+
try git(operation: .pull) {
71+
git_annotated_commit_lookup(&annotatedCommit, pointer, &remoteOID)
72+
}
73+
defer { git_annotated_commit_free(annotatedCommit) }
74+
75+
var annotatedCommits: [OpaquePointer?] = [annotatedCommit]
76+
77+
try git(operation: .pull) {
78+
annotatedCommits.withUnsafeMutableBufferPointer { buffer in
79+
git_merge_analysis(&analysis, &preference, pointer, buffer.baseAddress, 1)
80+
}
81+
}
82+
83+
// Check if we're already up to date
84+
if analysis.rawValue & GIT_MERGE_ANALYSIS_UP_TO_DATE.rawValue != 0 {
85+
// Already up to date, nothing to do
86+
return
87+
}
88+
89+
// Check if we can fast-forward
90+
if analysis.rawValue & GIT_MERGE_ANALYSIS_FASTFORWARD.rawValue != 0 {
91+
// Fast-forward merge: simply reset HEAD to the remote commit
92+
try reset(to: remoteCommit, mode: .hard)
93+
return
94+
}
95+
96+
// Normal merge required
97+
if analysis.rawValue & GIT_MERGE_ANALYSIS_NORMAL.rawValue != 0 {
98+
try performMerge(
99+
annotatedCommit: annotatedCommit!,
100+
remoteBranch: remoteBranch,
101+
remoteCommit: remoteCommit
102+
)
103+
return
104+
}
105+
106+
throw SwiftGitXError(
107+
code: .error, operation: .pull, category: .merge,
108+
message: "Merge analysis returned unexpected result"
109+
)
110+
}
111+
112+
// MARK: - Private Helpers
113+
114+
/// Performs a normal merge with the given annotated commit.
115+
private func performMerge(
116+
annotatedCommit: OpaquePointer,
117+
remoteBranch: Branch,
118+
remoteCommit: Commit
119+
) throws(SwiftGitXError) {
120+
// Initialize merge options
121+
var mergeOptions = git_merge_options()
122+
git_merge_options_init(&mergeOptions, UInt32(GIT_MERGE_OPTIONS_VERSION))
123+
124+
// Initialize checkout options
125+
var checkoutOptions = git_checkout_options()
126+
git_checkout_options_init(&checkoutOptions, UInt32(GIT_CHECKOUT_OPTIONS_VERSION))
127+
checkoutOptions.checkout_strategy = GIT_CHECKOUT_SAFE.rawValue
128+
129+
// Perform the merge
130+
var annotatedCommits: [OpaquePointer?] = [annotatedCommit]
131+
132+
try git(operation: .merge) {
133+
annotatedCommits.withUnsafeMutableBufferPointer { buffer in
134+
git_merge(pointer, buffer.baseAddress, 1, &mergeOptions, &checkoutOptions)
135+
}
136+
}
137+
138+
// Check for conflicts
139+
let index = try git(operation: .merge) {
140+
var indexPointer: OpaquePointer?
141+
let status = git_repository_index(&indexPointer, pointer)
142+
return (indexPointer, status)
143+
}
144+
defer { git_index_free(index) }
145+
146+
if git_index_has_conflicts(index) == 1 {
147+
// Clean up merge state
148+
git_repository_state_cleanup(pointer)
149+
150+
throw SwiftGitXError(
151+
code: .conflict, operation: .pull, category: .merge,
152+
message: "Merge conflicts detected. Please resolve conflicts manually."
153+
)
154+
}
155+
156+
// Create merge commit
157+
try createMergeCommit(remoteBranch: remoteBranch, remoteCommit: remoteCommit)
158+
159+
// Clean up merge state
160+
git_repository_state_cleanup(pointer)
161+
}
162+
163+
/// Creates a merge commit after a successful merge.
164+
private func createMergeCommit(remoteBranch: Branch, remoteCommit: Commit) throws(SwiftGitXError) {
165+
// Get the index
166+
let index = try git(operation: .merge) {
167+
var indexPointer: OpaquePointer?
168+
let status = git_repository_index(&indexPointer, pointer)
169+
return (indexPointer, status)
170+
}
171+
defer { git_index_free(index) }
172+
173+
// Write the index as a tree
174+
var treeOID = git_oid()
175+
try git(operation: .merge) {
176+
git_index_write_tree(&treeOID, index)
177+
}
178+
179+
// Get the tree
180+
let tree = try git(operation: .merge) {
181+
var treePointer: OpaquePointer?
182+
let status = git_tree_lookup(&treePointer, pointer, &treeOID)
183+
return (treePointer, status)
184+
}
185+
defer { git_tree_free(tree) }
186+
187+
// Get HEAD commit
188+
let headCommit = try HEAD.target as! Commit
189+
190+
// Get signature
191+
var signature: UnsafeMutablePointer<git_signature>?
192+
try git(operation: .merge) {
193+
git_signature_default(&signature, pointer)
194+
}
195+
defer { git_signature_free(signature) }
196+
197+
// Create merge commit message
198+
let message = "Merge branch '\(remoteBranch.displayName)'"
199+
200+
// Get parent commit pointers
201+
let headCommitPointer = try ObjectFactory.lookupObjectPointer(
202+
oid: headCommit.id.raw,
203+
type: GIT_OBJECT_COMMIT,
204+
repositoryPointer: pointer
205+
)
206+
defer { git_object_free(headCommitPointer) }
207+
208+
let remoteCommitPointer = try ObjectFactory.lookupObjectPointer(
209+
oid: remoteCommit.id.raw,
210+
type: GIT_OBJECT_COMMIT,
211+
repositoryPointer: pointer
212+
)
213+
defer { git_object_free(remoteCommitPointer) }
214+
215+
// Create the merge commit
216+
var commitOID = git_oid()
217+
var parents: [OpaquePointer?] = [headCommitPointer, remoteCommitPointer]
218+
219+
try git(operation: .merge) {
220+
parents.withUnsafeMutableBufferPointer { buffer in
221+
git_commit_create(
222+
&commitOID,
223+
pointer,
224+
"HEAD",
225+
signature,
226+
signature,
227+
nil,
228+
message,
229+
tree,
230+
2,
231+
buffer.baseAddress
232+
)
233+
}
234+
}
235+
}
236+
}
237+
238+
// MARK: - Branch Display Name Extension
239+
240+
extension Branch {
241+
/// Returns a display-friendly name for the branch.
242+
var displayName: String {
243+
if type == .remote {
244+
// Remove remote prefix (e.g., "origin/main" -> "main")
245+
let remoteName = remote?.name ?? "origin"
246+
return name.replacingOccurrences(of: "\(remoteName)/", with: "")
247+
}
248+
return name
249+
}
250+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import Foundation
2+
import SwiftGitX
3+
import Testing
4+
5+
@Suite("Repository - Pull", .tags(.repository, .operation, .pull))
6+
final class RepositoryPullTests: SwiftGitXTest {
7+
@Test("Pull from remote repository - fast forward")
8+
func pullFastForward() async throws {
9+
// Create remote repository with initial commit
10+
let remoteRepository = mockRepository(suffix: "--remote")
11+
try remoteRepository.mockCommit(message: "Initial commit")
12+
13+
// Clone to create local repository with tracking branch
14+
let localDirectory = mockDirectory(suffix: "--local")
15+
let localRepository = try await Repository.clone(from: remoteRepository.workingDirectory, to: localDirectory)
16+
17+
// Add another commit to the remote repository
18+
try remoteRepository.mockCommit(message: "Remote commit")
19+
20+
// Get initial local HEAD
21+
let initialLocalHead = try localRepository.HEAD.target.id
22+
23+
// Pull the changes
24+
try await localRepository.pull()
25+
26+
// Verify local HEAD has been updated
27+
let finalLocalHead = try localRepository.HEAD.target.id
28+
let remoteHead = try remoteRepository.HEAD.target.id
29+
30+
#expect(finalLocalHead == remoteHead)
31+
#expect(finalLocalHead != initialLocalHead)
32+
}
33+
34+
@Test("Pull when already up to date")
35+
func pullUpToDate() async throws {
36+
// Create remote repository with initial commit
37+
let remoteRepository = mockRepository(suffix: "--remote")
38+
try remoteRepository.mockCommit(message: "Initial commit")
39+
40+
// Clone to create local repository
41+
let localDirectory = mockDirectory(suffix: "--local")
42+
let localRepository = try await Repository.clone(from: remoteRepository.workingDirectory, to: localDirectory)
43+
44+
// Get initial local HEAD
45+
let initialLocalHead = try localRepository.HEAD.target.id
46+
47+
// Pull when already up to date
48+
try await localRepository.pull()
49+
50+
// Verify local HEAD is unchanged
51+
let finalLocalHead = try localRepository.HEAD.target.id
52+
#expect(finalLocalHead == initialLocalHead)
53+
}
54+
55+
@Test("Pull with normal merge")
56+
func pullNormalMerge() async throws {
57+
// Create remote repository with initial commit
58+
let remoteRepository = mockRepository(suffix: "--remote")
59+
try remoteRepository.mockCommit(message: "Initial commit")
60+
61+
// Clone to create local repository
62+
let localDirectory = mockDirectory(suffix: "--local")
63+
let localRepository = try await Repository.clone(from: remoteRepository.workingDirectory, to: localDirectory)
64+
65+
// Add a commit to the remote repository
66+
try remoteRepository.mockCommit(message: "Remote commit")
67+
68+
// Add a different commit to the local repository (creates divergence)
69+
try localRepository.mockCommit(message: "Local commit")
70+
71+
// Pull the changes (should create merge commit)
72+
try await localRepository.pull()
73+
74+
// Verify that we now have a merge commit (commit with 2 parents)
75+
let headCommit = try localRepository.HEAD.target as! Commit
76+
let parents = try headCommit.parents
77+
#expect(parents.count == 2)
78+
}
79+
80+
@Test("Pull fails without upstream branch")
81+
func pullWithoutUpstream() async throws {
82+
// Create a repository without remote
83+
let repository = mockRepository()
84+
try repository.mockCommit()
85+
86+
// Try to pull without upstream configured
87+
await #expect(throws: SwiftGitXError.self) {
88+
try await repository.pull()
89+
}
90+
}
91+
92+
@Test("Pull multiple commits")
93+
func pullMultipleCommits() async throws {
94+
// Create remote repository
95+
let remoteRepository = mockRepository(suffix: "--remote")
96+
try remoteRepository.mockCommit(message: "Initial commit")
97+
98+
// Clone to create local repository
99+
let localDirectory = mockDirectory(suffix: "--local")
100+
let localRepository = try await Repository.clone(from: remoteRepository.workingDirectory, to: localDirectory)
101+
102+
// Add multiple commits to remote
103+
try remoteRepository.mockCommit(message: "Second commit")
104+
try remoteRepository.mockCommit(message: "Third commit")
105+
try remoteRepository.mockCommit(message: "Fourth commit")
106+
107+
// Pull all changes
108+
try await localRepository.pull()
109+
110+
// Verify local HEAD matches remote HEAD
111+
let localHead = try localRepository.HEAD.target.id
112+
let remoteHead = try remoteRepository.HEAD.target.id
113+
#expect(localHead == remoteHead)
114+
115+
// Verify commit count matches
116+
let localCommitCount = try localRepository.log().reduce(0) { count, _ in count + 1 }
117+
let remoteCommitCount = try remoteRepository.log().reduce(0) { count, _ in count + 1 }
118+
#expect(localCommitCount == remoteCommitCount)
119+
}
120+
}

Tests/SwiftGitXTests/Tags.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ extension Testing.Tag {
1616
@Tag static var diff: Self
1717
@Tag static var fetch: Self
1818
@Tag static var log: Self
19+
@Tag static var merge: Self
1920
@Tag static var patch: Self
21+
@Tag static var pull: Self
2022
@Tag static var push: Self
2123
@Tag static var reset: Self
2224
@Tag static var restore: Self

0 commit comments

Comments
 (0)