diff --git a/ObjectiveGit/GTRemote.m b/ObjectiveGit/GTRemote.m index 56c97199f..25ffd2b3f 100644 --- a/ObjectiveGit/GTRemote.m +++ b/ObjectiveGit/GTRemote.m @@ -95,6 +95,10 @@ - (NSUInteger)hash { return self.name.hash ^ self.URLString.hash; } +- (NSString *)description { + return [NSString stringWithFormat:@"<%@: %p> name: %@, URLString: %@", NSStringFromClass([self class]), self, self.name, self.URLString]; +} + #pragma mark API + (BOOL)isValidRemoteName:(NSString *)name { diff --git a/ObjectiveGit/GTRepository+Pull.h b/ObjectiveGit/GTRepository+Pull.h new file mode 100644 index 000000000..463e52cdd --- /dev/null +++ b/ObjectiveGit/GTRepository+Pull.h @@ -0,0 +1,56 @@ +// +// GTRepository+Pull.h +// ObjectiveGitFramework +// +// Created by Ben Chatelain on 6/17/15. +// Copyright © 2015 GitHub, Inc. All rights reserved. +// + +#import "GTRepository.h" +#import "git2/merge.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An enum describing the result of the merge analysis. +/// See `git_merge_analysis_t`. +typedef NS_ENUM(NSInteger, GTMergeAnalysis) { + GTMergeAnalysisNone = GIT_MERGE_ANALYSIS_NONE, + GTMergeAnalysisNormal = GIT_MERGE_ANALYSIS_NORMAL, + GTMergeAnalysisUpToDate = GIT_MERGE_ANALYSIS_UP_TO_DATE, + GTMergeAnalysisUnborn = GIT_MERGE_ANALYSIS_UNBORN, + GTMergeAnalysisFastForward = GIT_MERGE_ANALYSIS_FASTFORWARD, +}; + +typedef void (^GTRemoteFetchTransferProgressBlock)(const git_transfer_progress *progress, BOOL *stop); + +@interface GTRepository (Pull) + +#pragma mark - Pull + +/// Pull a single branch from a remote. +/// +/// branch - The branch to pull. +/// remote - The remote to pull from. +/// options - Options applied to the fetch operation. +/// Recognized options are: +/// `GTRepositoryRemoteOptionsCredentialProvider` +/// error - The error if one occurred. Can be NULL. +/// progressBlock - An optional callback for monitoring progress. +/// +/// Returns YES if the pull was successful, NO otherwise (and `error`, if provided, +/// will point to an error describing what happened). +- (BOOL)pullBranch:(GTBranch *)branch fromRemote:(GTRemote *)remote withOptions:(nullable NSDictionary *)options error:(NSError **)error progress:(nullable GTRemoteFetchTransferProgressBlock)progressBlock; + +/// Analyze which merge to perform. +/// +/// analysis - The resulting analysis. +/// fromBranch - The branch to merge from. +/// error - The error if one occurred. Can be NULL. +/// +/// Returns YES if the analysis was successful, NO otherwise (and `error`, if provided, +/// will point to an error describing what happened). +- (BOOL)analyzeMerge:(GTMergeAnalysis *)analysis fromBranch:(GTBranch *)fromBranch error:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ObjectiveGit/GTRepository+Pull.m b/ObjectiveGit/GTRepository+Pull.m new file mode 100644 index 000000000..24bbb3e2d --- /dev/null +++ b/ObjectiveGit/GTRepository+Pull.m @@ -0,0 +1,168 @@ +// +// GTRepository+Pull.m +// ObjectiveGitFramework +// +// Created by Ben Chatelain on 6/17/15. +// Copyright © 2015 GitHub, Inc. All rights reserved. +// + +#import "GTRepository+Pull.h" + +#import "GTCommit.h" +#import "GTIndex.h" +#import "GTOID.h" +#import "GTRemote.h" +#import "GTReference.h" +#import "GTRepository+Committing.h" +#import "GTRepository+RemoteOperations.h" +#import "GTTree.h" +#import "NSError+Git.h" +#import "git2/errors.h" + +@implementation GTRepository (Pull) + +#pragma mark - Pull + +- (BOOL)pullBranch:(GTBranch *)branch fromRemote:(GTRemote *)remote withOptions:(NSDictionary *)options error:(NSError **)error progress:(GTRemoteFetchTransferProgressBlock)progressBlock { + NSParameterAssert(branch != nil); + NSParameterAssert(remote != nil); + + GTRepository *repo = branch.repository; + + if (![self fetchRemote:remote withOptions:options error:error progress:progressBlock]) { + return NO; + } + + // Get tracking branch after fetch so that it is up-to-date and doesn't need to be refreshed from disk + GTBranch *trackingBranch; + if (branch.branchType == GTBranchTypeLocal) { + BOOL success = NO; + trackingBranch = [branch trackingBranchWithError:error success:&success]; + if (!success) { + if (error != NULL) *error = [NSError git_errorFor:GIT_ERROR description:@"Tracking branch not found for %@", branch.name]; + return NO; + } + else if (!trackingBranch) { + // Error should already be provided by libgit2 + return NO; + } + } + else { + // When given a remote branch, use it as the tracking branch + trackingBranch = branch; + } + + // Check if merge is necessary + GTBranch *localBranch = [repo currentBranchWithError:error]; + if (!localBranch) { + return NO; + } + + GTCommit *localCommit = [localBranch targetCommitWithError:error]; + if (!localCommit) { + return NO; + } + + GTCommit *remoteCommit = [trackingBranch targetCommitWithError:error]; + if (!remoteCommit) { + return NO; + } + + if ([localCommit.SHA isEqualToString:remoteCommit.SHA]) { + // Local and remote tracking branch are already in sync + return YES; + } + + GTMergeAnalysis analysis = GTMergeAnalysisNone; + BOOL success = [self analyzeMerge:&analysis fromBranch:trackingBranch error:error]; + if (!success) { + return NO; + } + + if (analysis & GTMergeAnalysisUpToDate) { + // Nothing to do + return YES; + } else if (analysis & GTMergeAnalysisFastForward || + analysis & GTMergeAnalysisUnborn) { + // Fast-forward branch + NSString *message = [NSString stringWithFormat:@"merge %@/%@: Fast-forward", remote.name, trackingBranch.name]; + GTReference *reference = [localBranch.reference referenceByUpdatingTarget:remoteCommit.SHA message:message error:error]; + BOOL checkoutSuccess = [self checkoutReference:reference strategy:GTCheckoutStrategyForce error:error progressBlock:nil]; + + return checkoutSuccess; + } else if (analysis & GTMergeAnalysisNormal) { + // Do normal merge + GTTree *localTree = localCommit.tree; + GTTree *remoteTree = remoteCommit.tree; + + // TODO: Find common ancestor + GTTree *ancestorTree = nil; + + // Merge + GTIndex *index = [localTree merge:remoteTree ancestor:ancestorTree error:error]; + if (!index) { + return NO; + } + + // Check for conflict + if (index.hasConflicts) { + if (error != NULL) *error = [NSError git_errorFor:GIT_ECONFLICT description:@"Merge conflict, pull aborted"]; + return NO; + } + + GTTree *newTree = [index writeTreeToRepository:repo error:error]; + if (!newTree) { + return NO; + } + + // Create merge commit + NSString *message = [NSString stringWithFormat:@"Merge branch '%@'", localBranch.shortName]; + NSArray *parents = @[ localCommit, remoteCommit ]; + + // FIXME: This is stepping on the local tree + GTCommit *mergeCommit = [repo createCommitWithTree:newTree message:message parents:parents updatingReferenceNamed:localBranch.name error:error]; + if (!mergeCommit) { + return NO; + } + + BOOL success = [self checkoutReference:localBranch.reference strategy:GTCheckoutStrategyForce error:error progressBlock:nil]; + return success; + } + + return NO; +} + +- (BOOL)analyzeMerge:(GTMergeAnalysis *)analysis fromBranch:(GTBranch *)fromBranch error:(NSError **)error { + NSParameterAssert(analysis != NULL); + NSParameterAssert(fromBranch != nil); + + GTCommit *fromCommit = [fromBranch targetCommitWithError:error]; + if (!fromCommit) { + return NO; + } + + git_annotated_commit *annotatedCommit; + + int gitError = git_annotated_commit_lookup(&annotatedCommit, self.git_repository, fromCommit.OID.git_oid); + if (gitError != GIT_OK) { + if (error != NULL) *error = [NSError git_errorFor:gitError description:@"Failed to lookup annotated comit for %@", fromCommit]; + return NO; + } + + // Allow fast-forward or normal merge + git_merge_preference_t preference = GIT_MERGE_PREFERENCE_NONE; + + // Merge analysis + gitError = git_merge_analysis((git_merge_analysis_t *)analysis, &preference, self.git_repository, (const git_annotated_commit **) &annotatedCommit, 1); + if (gitError != GIT_OK) { + if (error != NULL) *error = [NSError git_errorFor:gitError description:@"Failed to analyze merge"]; + return NO; + } + + // Cleanup + git_annotated_commit_free(annotatedCommit); + + return YES; +} + +@end diff --git a/ObjectiveGit/GTRepository.m b/ObjectiveGit/GTRepository.m index 75d6ed5a6..49e59ce07 100644 --- a/ObjectiveGit/GTRepository.m +++ b/ObjectiveGit/GTRepository.m @@ -91,6 +91,9 @@ @interface GTRepository () @implementation GTRepository - (NSString *)description { + if (self.isBare) { + return [NSString stringWithFormat:@"<%@: %p> (bare) gitDirectoryURL: %@", self.class, self, self.gitDirectoryURL]; + } return [NSString stringWithFormat:@"<%@: %p> fileURL: %@", self.class, self, self.fileURL]; } @@ -366,7 +369,11 @@ - (GTReference *)headReferenceWithError:(NSError **)error { git_reference *headRef; int gitError = git_repository_head(&headRef, self.git_repository); if (gitError != GIT_OK) { - if (error != NULL) *error = [NSError git_errorFor:gitError description:@"Failed to get HEAD"]; + NSString *unborn = @""; + if (gitError == GIT_EUNBORNBRANCH) { + unborn = @" (unborn)"; + } + if (error != NULL) *error = [NSError git_errorFor:gitError description:@"Failed to get HEAD%@", unborn]; return nil; } diff --git a/ObjectiveGit/ObjectiveGit.h b/ObjectiveGit/ObjectiveGit.h index da4a672eb..ef8126cb3 100644 --- a/ObjectiveGit/ObjectiveGit.h +++ b/ObjectiveGit/ObjectiveGit.h @@ -40,6 +40,7 @@ FOUNDATION_EXPORT const unsigned char ObjectiveGitVersionString[]; #import #import #import +#import #import #import #import diff --git a/ObjectiveGitFramework.xcodeproj/project.pbxproj b/ObjectiveGitFramework.xcodeproj/project.pbxproj index bc9ed39f8..4a1ba8b93 100644 --- a/ObjectiveGitFramework.xcodeproj/project.pbxproj +++ b/ObjectiveGitFramework.xcodeproj/project.pbxproj @@ -319,6 +319,7 @@ F8D007701B4F7CA8009A8DAF /* NSErrorGitSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D0F4E28917C7F24200BBDE30 /* NSErrorGitSpec.m */; }; F8D007711B4F7CB0009A8DAF /* NSDataGitSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D01EFD9F195DEF2200838D24 /* NSDataGitSpec.m */; }; F8D007721B4F7CB6009A8DAF /* NSArray+StringArraySpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 307623AA17C6C8BD00E2CDF1 /* NSArray+StringArraySpec.m */; }; + F8D1BDEE1B31FE7C00CDEC90 /* GTRepository+Pull.h in Headers */ = {isa = PBXBuildFile; fileRef = F8D1BDEC1B31FE7C00CDEC90 /* GTRepository+Pull.h */; settings = {ATTRIBUTES = (Public, ); }; }; F8D007731B4F7CC3009A8DAF /* GTSignatureSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D040AF77177B9A9E001AD9EB /* GTSignatureSpec.m */; }; F8D007741B4F7CCC009A8DAF /* GTOIDSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D040AF6F177B9779001AD9EB /* GTOIDSpec.m */; }; F8D007761B4F7D10009A8DAF /* GTTimeAdditionsSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 30B1E7FF1703871900D0814D /* GTTimeAdditionsSpec.m */; }; @@ -349,6 +350,7 @@ F8D0079E1B4FA03B009A8DAF /* GTTreeSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 88328127173D8A64006D7DCF /* GTTreeSpec.m */; }; F8D0079F1B4FA03B009A8DAF /* GTObjectDatabaseSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 88948AC81779243600809CDA /* GTObjectDatabaseSpec.m */; }; F8D007A01B4FA03B009A8DAF /* GTRepository+StatusSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 30A269AC17B4878C000FE64E /* GTRepository+StatusSpec.m */; }; + F8D1BDEF1B31FE7C00CDEC90 /* GTRepository+Pull.h in Headers */ = {isa = PBXBuildFile; fileRef = F8D1BDEC1B31FE7C00CDEC90 /* GTRepository+Pull.h */; settings = {ATTRIBUTES = (Public, ); }; }; F8D007A11B4FA03B009A8DAF /* GTRepositoryStashingSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D015F7D417F6965400AD5E1F /* GTRepositoryStashingSpec.m */; }; F8D007A21B4FA03B009A8DAF /* GTFilterSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 886E623618AECD86000611A0 /* GTFilterSpec.m */; }; F8D007A31B4FA03B009A8DAF /* GTFilterListSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D0751CD818BE520400134314 /* GTFilterListSpec.m */; }; @@ -356,8 +358,12 @@ F8D007A51B4FA03B009A8DAF /* GTDiffDeltaSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 8870390A1975E3F2004118D7 /* GTDiffDeltaSpec.m */; }; F8D007A61B4FA03B009A8DAF /* GTRepositoryAttributesSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 88E353051982EA6B0051001F /* GTRepositoryAttributesSpec.m */; }; F8D007A71B4FA040009A8DAF /* QuickSpec+GTFixtures.m in Sources */ = {isa = PBXBuildFile; fileRef = 88A994CA16FCED1D00402C7B /* QuickSpec+GTFixtures.m */; }; + F8D1BDF01B31FE7C00CDEC90 /* GTRepository+Pull.m in Sources */ = {isa = PBXBuildFile; fileRef = F8D1BDED1B31FE7C00CDEC90 /* GTRepository+Pull.m */; }; + F8D1BDF11B31FE7C00CDEC90 /* GTRepository+Pull.m in Sources */ = {isa = PBXBuildFile; fileRef = F8D1BDED1B31FE7C00CDEC90 /* GTRepository+Pull.m */; }; F8D007A81B4FA045009A8DAF /* fixtures.zip in Resources */ = {isa = PBXBuildFile; fileRef = D09C2E50175602A500065E36 /* fixtures.zip */; }; F8E4A2911A170CA6006485A8 /* GTRemotePushSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = F8E4A2901A170CA6006485A8 /* GTRemotePushSpec.m */; }; + F8EFA0371B405020000FF7D0 /* GTRepository+PullSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = F8EFA0361B405020000FF7D0 /* GTRepository+PullSpec.m */; }; + F8EFA03A1B4059ED000FF7D0 /* GTUtilityFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = F8EFA0391B4059ED000FF7D0 /* GTUtilityFunctions.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -622,13 +628,18 @@ F879D82F1B4B77F4002D5C07 /* Libgit2FeaturesSpec.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Libgit2FeaturesSpec.m; sourceTree = ""; }; F879D8361B4B7F7C002D5C07 /* ObjectiveGit-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "ObjectiveGit-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; F879D8461B4B8138002D5C07 /* Nimble.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Nimble.framework; path = "Carthage/Checkouts/Nimble/build/Debug-iphoneos/Nimble.framework"; sourceTree = ""; }; + F8D1BDEC1B31FE7C00CDEC90 /* GTRepository+Pull.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "GTRepository+Pull.h"; sourceTree = ""; }; F8D007801B4F9758009A8DAF /* SSZipArchive.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SSZipArchive.m; path = Carthage/Checkouts/ZipArchive/SSZipArchive/SSZipArchive.m; sourceTree = ""; }; F8D007821B4F97F9009A8DAF /* ioapi.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = ioapi.c; path = Carthage/Checkouts/ZipArchive/SSZipArchive/minizip/ioapi.c; sourceTree = ""; }; F8D007831B4F97F9009A8DAF /* mztools.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = mztools.c; path = Carthage/Checkouts/ZipArchive/SSZipArchive/minizip/mztools.c; sourceTree = ""; }; + F8D1BDED1B31FE7C00CDEC90 /* GTRepository+Pull.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "GTRepository+Pull.m"; sourceTree = ""; }; F8D007841B4F97F9009A8DAF /* unzip.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = unzip.c; path = Carthage/Checkouts/ZipArchive/SSZipArchive/minizip/unzip.c; sourceTree = ""; }; F8D007851B4F97F9009A8DAF /* zip.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = zip.c; path = Carthage/Checkouts/ZipArchive/SSZipArchive/minizip/zip.c; sourceTree = ""; }; F8D0078A1B4F9F9E009A8DAF /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS8.4.sdk/usr/lib/libz.dylib; sourceTree = DEVELOPER_DIR; }; F8E4A2901A170CA6006485A8 /* GTRemotePushSpec.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GTRemotePushSpec.m; sourceTree = ""; }; + F8EFA0361B405020000FF7D0 /* GTRepository+PullSpec.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "GTRepository+PullSpec.m"; sourceTree = ""; }; + F8EFA0381B4059ED000FF7D0 /* GTUtilityFunctions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GTUtilityFunctions.h; sourceTree = ""; }; + F8EFA0391B4059ED000FF7D0 /* GTUtilityFunctions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GTUtilityFunctions.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -782,41 +793,43 @@ isa = PBXGroup; children = ( F84E92581B8B6EA30019F947 /* SSZipArchive */, - 88F05A7616011E5400B7AD1D /* Supporting Files */, + 200578C418932A82001C06C3 /* GTBlameSpec.m */, 4D1C40D7182C006D00BE2960 /* GTBlobSpec.m */, 88A994B916FCE7D400402C7B /* GTBranchSpec.m */, 88F05AA416011FFD00B7AD1D /* GTCommitSpec.m */, 88C0BC5817038CF3009E99AA /* GTConfigurationSpec.m */, + 8870390A1975E3F2004118D7 /* GTDiffDeltaSpec.m */, 30865A90167F503400B1AB6E /* GTDiffSpec.m */, D06D9E001755D10000558C17 /* GTEnumeratorSpec.m */, + D0751CD818BE520400134314 /* GTFilterListSpec.m */, + 886E623618AECD86000611A0 /* GTFilterSpec.m */, 8832811E173D8816006D7DCF /* GTIndexSpec.m */, + 88948AC81779243600809CDA /* GTObjectDatabaseSpec.m */, 88F05AA816011FFD00B7AD1D /* GTObjectSpec.m */, + D040AF6F177B9779001AD9EB /* GTOIDSpec.m */, D00F6815175D373C004DB9D6 /* GTReferenceSpec.m */, 88215482171499BE00D76B76 /* GTReflogSpec.m */, F8E4A2901A170CA6006485A8 /* GTRemotePushSpec.m */, 4DBA4A3117DA73CE006CD5F5 /* GTRemoteSpec.m */, - 200578C418932A82001C06C3 /* GTBlameSpec.m */, - D0AC906B172F941F00347DC4 /* GTRepositorySpec.m */, + F8EFA0361B405020000FF7D0 /* GTRepository+PullSpec.m */, + 30A269AC17B4878C000FE64E /* GTRepository+StatusSpec.m */, + 88E353051982EA6B0051001F /* GTRepositoryAttributesSpec.m */, 4D12323F178E009E0048F785 /* GTRepositoryCommittingSpec.m */, + 88234B2518F2FE260039972E /* GTRepositoryResetSpec.m */, + D0AC906B172F941F00347DC4 /* GTRepositorySpec.m */, + D015F7D417F6965400AD5E1F /* GTRepositoryStashingSpec.m */, + D040AF77177B9A9E001AD9EB /* GTSignatureSpec.m */, D03B7C401756AB370034A610 /* GTSubmoduleSpec.m */, 2089E43B17D9A58000F451DA /* GTTagSpec.m */, 30B1E7FF1703871900D0814D /* GTTimeAdditionsSpec.m */, 5BE612921745EEBC00266D8C /* GTTreeBuilderSpec.m */, 88328127173D8A64006D7DCF /* GTTreeSpec.m */, - 88948AC81779243600809CDA /* GTObjectDatabaseSpec.m */, - D040AF6F177B9779001AD9EB /* GTOIDSpec.m */, - D040AF77177B9A9E001AD9EB /* GTSignatureSpec.m */, - 30A269AC17B4878C000FE64E /* GTRepository+StatusSpec.m */, + F879D82F1B4B77F4002D5C07 /* Libgit2FeaturesSpec.m */, 307623AA17C6C8BD00E2CDF1 /* NSArray+StringArraySpec.m */, - D0F4E28917C7F24200BBDE30 /* NSErrorGitSpec.m */, D01EFD9F195DEF2200838D24 /* NSDataGitSpec.m */, - D015F7D417F6965400AD5E1F /* GTRepositoryStashingSpec.m */, - 886E623618AECD86000611A0 /* GTFilterSpec.m */, - D0751CD818BE520400134314 /* GTFilterListSpec.m */, - 88234B2518F2FE260039972E /* GTRepositoryResetSpec.m */, 8870390A1975E3F2004118D7 /* GTDiffDeltaSpec.m */, - 88E353051982EA6B0051001F /* GTRepositoryAttributesSpec.m */, - F879D82F1B4B77F4002D5C07 /* Libgit2FeaturesSpec.m */, + D0F4E28917C7F24200BBDE30 /* NSErrorGitSpec.m */, + 88F05A7616011E5400B7AD1D /* Supporting Files */, ); path = ObjectiveGitTests; sourceTree = ""; @@ -824,11 +837,13 @@ 88F05A7616011E5400B7AD1D /* Supporting Files */ = { isa = PBXGroup; children = ( + D09C2E50175602A500065E36 /* fixtures.zip */, + F8EFA0381B4059ED000FF7D0 /* GTUtilityFunctions.h */, + F8EFA0391B4059ED000FF7D0 /* GTUtilityFunctions.m */, D01B6F0F19F82F3C00D411BC /* Info.plist */, 88A994C916FCED1D00402C7B /* QuickSpec+GTFixtures.h */, 88A994CA16FCED1D00402C7B /* QuickSpec+GTFixtures.m */, D0A0129619F9A660007F1914 /* SwiftSpec.swift */, - D09C2E50175602A500065E36 /* fixtures.zip */, ); name = "Supporting Files"; sourceTree = ""; @@ -852,6 +867,8 @@ D015F7C917F695E800AD5E1F /* GTRepository+Stashing.m */, 88746CC217FA1C950005888A /* GTRepository+Committing.h */, 88746CC317FA1C950005888A /* GTRepository+Committing.m */, + F8D1BDEC1B31FE7C00CDEC90 /* GTRepository+Pull.h */, + F8D1BDED1B31FE7C00CDEC90 /* GTRepository+Pull.m */, 4DFFB159183AA8D600D1565E /* GTRepository+RemoteOperations.h */, 4DFFB15A183AA8D600D1565E /* GTRepository+RemoteOperations.m */, 88B2131A1B20E785005CF2C5 /* GTRepository+References.h */, @@ -1088,6 +1105,7 @@ 30DCBA5C17B45213009B0EBD /* GTStatusDelta.h in Headers */, 88746CC417FA1C950005888A /* GTRepository+Committing.h in Headers */, D09C2E361755F16200065E36 /* GTSubmodule.h in Headers */, + F8D1BDEE1B31FE7C00CDEC90 /* GTRepository+Pull.h in Headers */, 4D79C0EE17DF9F4D00997DE4 /* GTCredential.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1147,6 +1165,7 @@ D01B6F1B19F82F7B00D411BC /* NSDate+GTTimeAdditions.h in Headers */, D01B6F6319F82FA600D411BC /* GTFilterList.h in Headers */, 889923FB19FF5DD40092A9A6 /* git2 in Headers */, + F8D1BDEF1B31FE7C00CDEC90 /* GTRepository+Pull.h in Headers */, D01B6F1419F82F6000D411BC /* git2.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1379,6 +1398,7 @@ 2089E43C17D9A58000F451DA /* GTTagSpec.m in Sources */, D015F7D517F6965400AD5E1F /* GTRepositoryStashingSpec.m in Sources */, 88A994CB16FCED1D00402C7B /* QuickSpec+GTFixtures.m in Sources */, + F8EFA03A1B4059ED000FF7D0 /* GTUtilityFunctions.m in Sources */, 30B1E8001703871900D0814D /* GTTimeAdditionsSpec.m in Sources */, 8870390B1975E3F2004118D7 /* GTDiffDeltaSpec.m in Sources */, 88C0BC5917038CF3009E99AA /* GTConfigurationSpec.m in Sources */, @@ -1388,6 +1408,7 @@ D0AC906C172F941F00347DC4 /* GTRepositorySpec.m in Sources */, D01EFDA0195DEF2200838D24 /* NSDataGitSpec.m in Sources */, 30A269AD17B4878C000FE64E /* GTRepository+StatusSpec.m in Sources */, + F8EFA0371B405020000FF7D0 /* GTRepository+PullSpec.m in Sources */, 307623AB17C6C8BD00E2CDF1 /* NSArray+StringArraySpec.m in Sources */, 8832811F173D8816006D7DCF /* GTIndexSpec.m in Sources */, D0F4E28A17C7F24200BBDE30 /* NSErrorGitSpec.m in Sources */, @@ -1424,6 +1445,7 @@ BD6B0418131496CC001909D0 /* GTTreeEntry.m in Sources */, DD3D951D182AB25C004AF532 /* GTBlameHunk.m in Sources */, 306123AE17EA5261006591D4 /* EXTScope.m in Sources */, + F8D1BDF01B31FE7C00CDEC90 /* GTRepository+Pull.m in Sources */, BDD6279A1318391200DE34D1 /* GTBlob.m in Sources */, BDD62925131C03D600DE34D1 /* GTTag.m in Sources */, BDFAF9C4131C1845000508BC /* GTIndex.m in Sources */, @@ -1482,6 +1504,7 @@ 884C8A3A19FF4B890017E98D /* EXTScope.m in Sources */, D01B6F4E19F82F8700D411BC /* GTRemote.m in Sources */, D01B6F3019F82F8700D411BC /* GTObject.m in Sources */, + F8D1BDF11B31FE7C00CDEC90 /* GTRepository+Pull.m in Sources */, D01B6F4619F82F8700D411BC /* GTBranch.m in Sources */, D01B6F3A19F82F8700D411BC /* GTTreeEntry.m in Sources */, D01B6F2419F82F8700D411BC /* GTRepository+Reset.m in Sources */, diff --git a/ObjectiveGitFramework.xcworkspace/contents.xcworkspacedata b/ObjectiveGitFramework.xcworkspace/contents.xcworkspacedata index d1ebe6523..c99ffb396 100644 --- a/ObjectiveGitFramework.xcworkspace/contents.xcworkspacedata +++ b/ObjectiveGitFramework.xcworkspace/contents.xcworkspacedata @@ -4,6 +4,9 @@ + + diff --git a/ObjectiveGitTests/GTRemotePushSpec.m b/ObjectiveGitTests/GTRemotePushSpec.m index d5c768da6..050cb10aa 100644 --- a/ObjectiveGitTests/GTRemotePushSpec.m +++ b/ObjectiveGitTests/GTRemotePushSpec.m @@ -11,36 +11,7 @@ #import #import "QuickSpec+GTFixtures.h" - -// Helper to quickly create commits -GTCommit *(^createCommitInRepository)(NSString *, NSData *, NSString *, GTRepository *) = ^ GTCommit * (NSString *message, NSData *fileData, NSString *fileName, GTRepository *repo) { - GTTreeBuilder *treeBuilder = [[GTTreeBuilder alloc] initWithTree:nil repository:repo error:nil]; - [treeBuilder addEntryWithData:fileData fileName:fileName fileMode:GTFileModeBlob error:nil]; - - GTTree *testTree = [treeBuilder writeTree:nil]; - - // We need the parent commit to make the new one - GTReference *headReference = [repo headReferenceWithError:nil]; - - GTEnumerator *commitEnum = [[GTEnumerator alloc] initWithRepository:repo error:nil]; - [commitEnum pushSHA:[headReference targetOID].SHA error:nil]; - GTCommit *parent = [commitEnum nextObject]; - - GTCommit *testCommit = [repo createCommitWithTree:testTree message:message parents:@[ parent ] updatingReferenceNamed:headReference.name error:nil]; - expect(testCommit).notTo(beNil()); - - return testCommit; -}; - -GTBranch *(^localBranchWithName)(NSString *, GTRepository *) = ^ GTBranch * (NSString *branchName, GTRepository *repo) { - NSString *reference = [GTBranch.localNamePrefix stringByAppendingString:branchName]; - NSArray *branches = [repo branchesWithPrefix:reference error:NULL]; - expect(branches).notTo(beNil()); - expect(@(branches.count)).to(equal(@1)); - expect(((GTBranch *)branches[0]).shortName).to(equal(branchName)); - - return branches[0]; -}; +#import "GTUtilityFunctions.h" #pragma mark - GTRemotePushSpec diff --git a/ObjectiveGitTests/GTRemoteSpec.m b/ObjectiveGitTests/GTRemoteSpec.m index a59e37527..3656f8cdf 100644 --- a/ObjectiveGitTests/GTRemoteSpec.m +++ b/ObjectiveGitTests/GTRemoteSpec.m @@ -11,6 +11,7 @@ #import #import "QuickSpec+GTFixtures.h" +#import "GTUtilityFunctions.h" QuickSpecBegin(GTRemoteSpec) @@ -140,26 +141,6 @@ }); }); - // Helper to quickly create commits - GTCommit *(^createCommitInRepository)(NSString *, NSData *, NSString *, GTRepository *) = ^(NSString *message, NSData *fileData, NSString *fileName, GTRepository *repo) { - GTTreeBuilder *treeBuilder = [[GTTreeBuilder alloc] initWithTree:nil repository:repo error:nil]; - [treeBuilder addEntryWithData:fileData fileName:fileName fileMode:GTFileModeBlob error:nil]; - - GTTree *testTree = [treeBuilder writeTree:nil]; - - // We need the parent commit to make the new one - GTReference *headReference = [repo headReferenceWithError:nil]; - - GTEnumerator *commitEnum = [[GTEnumerator alloc] initWithRepository:repo error:nil]; - [commitEnum pushSHA:headReference.targetOID.SHA error:nil]; - GTCommit *parent = [commitEnum nextObject]; - - GTCommit *testCommit = [repo createCommitWithTree:testTree message:message parents:@[parent] updatingReferenceNamed:headReference.name error:nil]; - expect(testCommit).notTo(beNil()); - - return testCommit; - }; - describe(@"-[GTRepository fetchRemote:withOptions:error:progress:]", ^{ it(@"allows remotes to be fetched", ^{ NSError *error = nil; diff --git a/ObjectiveGitTests/GTRepository+PullSpec.m b/ObjectiveGitTests/GTRepository+PullSpec.m new file mode 100644 index 000000000..6c4dd3b78 --- /dev/null +++ b/ObjectiveGitTests/GTRepository+PullSpec.m @@ -0,0 +1,268 @@ +// +// GTRepository+PullSpec.m +// ObjectiveGitFramework +// +// Created by Ben Chatelain on 6/28/15. +// Copyright (c) 2015 GitHub, Inc. All rights reserved. +// + +#import +#import +#import +#import + +#import "QuickSpec+GTFixtures.h" +#import "GTUtilityFunctions.h" + +#pragma mark - GTRepository+PullSpec + +QuickSpecBegin(GTRepositoryPullSpec) + +describe(@"pull", ^{ + __block GTRepository *notBareRepo; + + beforeEach(^{ + notBareRepo = self.bareFixtureRepository; + expect(notBareRepo).notTo(beNil()); + // This repo is not really "bare" according to libgit2 + expect(@(notBareRepo.isBare)).to(beFalsy()); + }); + + describe(@"from remote", ^{ // via local transport + __block NSURL *remoteRepoURL; + __block NSURL *localRepoURL; + __block GTRepository *remoteRepo; + __block GTRepository *localRepo; + __block GTRemote *remote; + __block NSError *error; + + beforeEach(^{ + // Make a bare clone to serve as the remote + remoteRepoURL = [notBareRepo.gitDirectoryURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:@"bare_remote_repo.git"]; + NSDictionary *options = @{ GTRepositoryCloneOptionsBare: @1 }; + remoteRepo = [GTRepository cloneFromURL:notBareRepo.gitDirectoryURL toWorkingDirectory:remoteRepoURL options:options error:&error transferProgressBlock:NULL checkoutProgressBlock:NULL]; + expect(error).to(beNil()); + expect(remoteRepo).notTo(beNil()); + expect(@(remoteRepo.isBare)).to(beTruthy()); // that's better + + localRepoURL = [remoteRepoURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:@"local_pull_repo"]; + expect(localRepoURL).notTo(beNil()); + + // Local clone for testing pushes + localRepo = [GTRepository cloneFromURL:remoteRepoURL toWorkingDirectory:localRepoURL options:nil error:&error transferProgressBlock:NULL checkoutProgressBlock:NULL]; + + expect(error).to(beNil()); + expect(localRepo).notTo(beNil()); + + GTConfiguration *configuration = [localRepo configurationWithError:&error]; + expect(error).to(beNil()); + expect(configuration).notTo(beNil()); + + expect(@(configuration.remotes.count)).to(equal(@1)); + + remote = configuration.remotes[0]; + expect(remote.name).to(equal(@"origin")); + }); + + afterEach(^{ + [NSFileManager.defaultManager removeItemAtURL:remoteRepoURL error:NULL]; + [NSFileManager.defaultManager removeItemAtURL:localRepoURL error:NULL]; + error = NULL; + [self tearDown]; + }); + + context(@"when the local and remote branches are in sync", ^{ + it(@"should pull no commits", ^{ + GTBranch *masterBranch = localBranchWithName(@"master", localRepo); + expect(@([masterBranch numberOfCommitsWithError:NULL])).to(equal(@3)); + + GTBranch *remoteMasterBranch = localBranchWithName(@"master", remoteRepo); + expect(@([remoteMasterBranch numberOfCommitsWithError:NULL])).to(equal(@3)); + + // Pull + __block BOOL transferProgressed = NO; + BOOL result = [localRepo pullBranch:masterBranch fromRemote:remote withOptions:nil error:&error progress:^(const git_transfer_progress *progress, BOOL *stop) { + transferProgressed = YES; + }]; + expect(error).to(beNil()); + expect(@(result)).to(beTruthy()); + expect(@(transferProgressed)).to(beFalsy()); // Local transport doesn't currently call progress callbacks + + // Same number of commits after pull, refresh branch from disk first + remoteMasterBranch = localBranchWithName(@"master", remoteRepo); + expect(@([remoteMasterBranch numberOfCommitsWithError:NULL])).to(equal(@3)); + }); + }); + +/* pending tests break build on travis + /// Unborn + /// Can't get a GTBranch reference wrapping HEAD when its symref is unborn + pending(@"into an empty repo", ^{ + // Create an empty local repo + localRepoURL = [remoteRepoURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:@"empty_pull_repo"]; + NSLog(@"localRepoURL: %@", localRepoURL); + NSDictionary *options = @{ GTRepositoryInitOptionsOriginURLString: [remoteRepoURL absoluteString] }; + localRepo = [GTRepository initializeEmptyRepositoryAtFileURL:localRepoURL options:options error:&error]; + expect(localRepo).toNot(beNil()); + expect(error).to(beNil()); + + // Verify unborn + expect(@(localRepo.isHEADUnborn)).to(beTruthy()); + + // Configure tracking + GTConfiguration *configuration = [localRepo configurationWithError:&error]; + expect(configuration).toNot(beNil()); + expect(error).to(beNil()); + [configuration setString:@"origin" forKey:@"branch.master.remote"]; + [configuration setString:@"refs/heads/master" forKey:@"branch.master.merge"]; + + GTReference *head = [localRepo headReferenceWithError:&error]; + expect(head).toNot(beNil()); + expect(error).to(beNil()); + +// GTBranch *masterBranch = localBranchWithName(@"master", localRepo); + GTBranch *masterBranch = [localRepo currentBranchWithError:&error]; + expect(masterBranch).toNot(beNil()); + + // Pull + __block BOOL transferProgressed = NO; + BOOL result = [localRepo pullBranch:masterBranch fromRemote:remote withOptions:nil error:&error progress:^(const git_transfer_progress *progress, BOOL *stop) { + transferProgressed = YES; + }]; + expect(@(result)).to(beTruthy()); + expect(error).to(beNil()); + expect(@(transferProgressed)).to(beFalsy()); // Local transport doesn't currently call progress callbacks + +// GTReference *head = [localRepo headReferenceWithError:&error]; +// expect(head).toNot(beNil()); + + }); +*/ + + /// Fast-Forward Merge + /// + /// Stages a pull by modifying a clone, resetting it back in history + /// then using pull to bring the repos back in sync. + it(@"fast-forwards one commit", ^{ + GTBranch *masterBranch = localBranchWithName(@"master", localRepo); + expect(@([masterBranch numberOfCommitsWithError:NULL])).to(equal(@3)); + + // Reset local master back one commit + GTCommit *commit = [localRepo lookUpObjectByRevParse:@"HEAD^" error:&error]; + BOOL success = [localRepo resetToCommit:commit resetType:GTRepositoryResetTypeHard error:&error]; + expect(@(success)).to(beTruthy()); + expect(error).to(beNil()); + + // Verify rollback, must refresh branch from disk + masterBranch = localBranchWithName(@"master", localRepo); + expect(@([masterBranch numberOfCommitsWithError:NULL])).to(equal(@2)); + + // HEADs point to different objects + expect([[localRepo headReferenceWithError:NULL] OID]) + .toNot(equal([[remoteRepo headReferenceWithError:NULL] OID])); + + // Remote has 3 commits + GTBranch *remoteMasterBranch = localBranchWithName(@"master", remoteRepo); + expect(@([remoteMasterBranch numberOfCommitsWithError:NULL])).to(equal(@3)); + + // Pull + __block BOOL transferProgressed = NO; + BOOL result = [localRepo pullBranch:masterBranch fromRemote:remote withOptions:nil error:&error progress:^(const git_transfer_progress *progress, BOOL *stop) { + transferProgressed = YES; + }]; + expect(error).to(beNil()); + expect(@(result)).to(beTruthy()); + expect(@(transferProgressed)).to(beFalsy()); // Local transport doesn't currently call progress callbacks + + // Verify same number of commits after pull, refresh branch from disk first + masterBranch = localBranchWithName(@"master", localRepo); + expect(@([masterBranch numberOfCommitsWithError:NULL])).to(equal(@3)); + + // Verify HEADs are in sync + expect([[localRepo headReferenceWithError:NULL] OID]) + .to(equal([[remoteRepo headReferenceWithError:NULL] OID])); + }); + + /// Normal Merge + it(@"merges the upstream changes", ^{ + // 3 commits + GTBranch *masterBranch = localBranchWithName(@"master", localRepo); + expect(@([masterBranch numberOfCommitsWithError:NULL])).to(equal(@3)); + + // Create a new commit in the local repo + GTCommit *localCommit = createCommitInRepository(@"Local commit", [@"Test" dataUsingEncoding:NSUTF8StringEncoding], @"test.txt", localRepo); + expect(localCommit).notTo(beNil()); + + // 4 commits + masterBranch = localBranchWithName(@"master", localRepo); + expect(@([masterBranch numberOfCommitsWithError:NULL])).to(equal(@4)); + + localCommit = [localRepo lookUpObjectByOID:localCommit.OID objectType:GTObjectTypeCommit error:&error]; + expect(localCommit).notTo(beNil()); + expect(error).to(beNil()); + + // Create a new commit in the remote repo + GTCommit *upstreamCommit = createCommitInRepository(@"Upstream commit", [@"# So Fancy" dataUsingEncoding:NSUTF8StringEncoding], @"fancy.md", remoteRepo); + expect(upstreamCommit).notTo(beNil()); + + masterBranch = localBranchWithName(@"master", localRepo); + + // Validate there is one unique local commit before merge + BOOL success = NO; + GTBranch *remoteTrackingBranch = [masterBranch trackingBranchWithError:&error success:&success]; + expect(@(success)).to(beTrue()); + expect(error).to(beNil()); + expect(remoteTrackingBranch).toNot(beNil()); + + NSArray *uniqueLocalCommits = [localRepo localCommitsRelativeToRemoteBranch:remoteTrackingBranch error:&error]; + expect(uniqueLocalCommits).toNot(beNil()); + expect(error).to(beNil()); + expect(@(uniqueLocalCommits.count)).to(equal(@1)); + + // Pull + __block BOOL transferProgressed = NO; + BOOL result = [localRepo pullBranch:masterBranch fromRemote:remote withOptions:nil error:&error progress:^(const git_transfer_progress *progress, BOOL *stop) { + transferProgressed = YES; + }]; + expect(@(result)).to(beTruthy()); + expect(error).to(beNil()); + expect(@(transferProgressed)).to(beTruthy()); + + // Validate + + // 5 commits + masterBranch = localBranchWithName(@"master", localRepo); + expect(@([masterBranch numberOfCommitsWithError:NULL])).to(equal(@6)); + + // We should have have an additional merge commit after the pull + uniqueLocalCommits = [localRepo localCommitsRelativeToRemoteBranch:remoteTrackingBranch error:&error]; + expect(uniqueLocalCommits).toNot(beNil()); + expect(error).to(beNil()); + expect(@(uniqueLocalCommits.count)).to(equal(@3)); + }); + + /// Conflict During Merge + it(@"fails to merge when there is a conflict", ^{ + // Stage a conflict by adding the same file with different contents to both repos + GTCommit *localCommit = createCommitInRepository(@"Local commit", [@"TestLocal" dataUsingEncoding:NSUTF8StringEncoding], @"test.txt", localRepo); + expect(localCommit).notTo(beNil()); + GTCommit *remoteCommit = createCommitInRepository(@"Upstream commit", [@"TestUpstream" dataUsingEncoding:NSUTF8StringEncoding], @"test.txt", remoteRepo); + expect(remoteCommit).notTo(beNil()); + + GTBranch *masterBranch = localBranchWithName(@"master", localRepo); + + // Pull + __block BOOL transferProgressed = NO; + BOOL result = [localRepo pullBranch:masterBranch fromRemote:remote withOptions:nil error:&error progress:^(const git_transfer_progress *progress, BOOL *stop) { + transferProgressed = YES; + }]; + expect(@(result)).to(beFalsy()); + expect(error).toNot(beNil()); + expect(@(transferProgressed)).to(beTruthy()); + }); + + }); + +}); + +QuickSpecEnd diff --git a/ObjectiveGitTests/GTUtilityFunctions.h b/ObjectiveGitTests/GTUtilityFunctions.h new file mode 100644 index 000000000..73741692e --- /dev/null +++ b/ObjectiveGitTests/GTUtilityFunctions.h @@ -0,0 +1,31 @@ +// +// GTUtilityFunctions.h +// ObjectiveGitFramework +// +// Created by Ben Chatelain on 6/28/15. +// Copyright (c) 2015 GitHub, Inc. All rights reserved. +// + +#import +#import +#import + +@import Foundation; + +@class GTBranch; +@class GTCommit; +@class GTRepository; + +#pragma mark - Commit + +typedef GTCommit *(^CreateCommitBlock)(NSString *message, NSData *fileData, NSString *fileName, GTRepository *repo); + +// Helper to quickly create commits +extern CreateCommitBlock createCommitInRepository; + +#pragma mark - Branch + +typedef GTBranch *(^BranchBlock)(NSString *, GTRepository *); + +// Helper to retrieve a branch by name +extern BranchBlock localBranchWithName; diff --git a/ObjectiveGitTests/GTUtilityFunctions.m b/ObjectiveGitTests/GTUtilityFunctions.m new file mode 100644 index 000000000..95889e6de --- /dev/null +++ b/ObjectiveGitTests/GTUtilityFunctions.m @@ -0,0 +1,50 @@ +// +// GTUtilityFunctions.m +// ObjectiveGitFramework +// +// Created by Ben Chatelain on 6/28/15. +// Copyright (c) 2015 GitHub, Inc. All rights reserved. +// + +#import +#import +#import + +#import "GTUtilityFunctions.h" + +#pragma mark - Commit + +CreateCommitBlock createCommitInRepository = ^ GTCommit * (NSString *message, NSData *fileData, NSString *fileName, GTRepository *repo) { + GTReference *head = [repo headReferenceWithError:NULL]; + GTBranch *branch = [GTBranch branchWithReference:head repository:repo]; + GTCommit *headCommit = [branch targetCommitWithError:NULL]; + + GTTreeBuilder *treeBuilder = [[GTTreeBuilder alloc] initWithTree:headCommit.tree repository:repo error:nil]; + [treeBuilder addEntryWithData:fileData fileName:fileName fileMode:GTFileModeBlob error:nil]; + + GTTree *testTree = [treeBuilder writeTree:nil]; + + // We need the parent commit to make the new one + GTReference *headReference = [repo headReferenceWithError:nil]; + + GTEnumerator *commitEnum = [[GTEnumerator alloc] initWithRepository:repo error:nil]; + [commitEnum pushSHA:[headReference targetOID].SHA error:nil]; + GTCommit *parent = [commitEnum nextObject]; + + GTCommit *testCommit = [repo createCommitWithTree:testTree message:message parents:@[ parent ] updatingReferenceNamed:headReference.name error:nil]; + expect(testCommit).notTo(beNil()); + + return testCommit; +}; + +#pragma mark - Branch + +BranchBlock localBranchWithName = ^ GTBranch * (NSString *branchName, GTRepository *repo) { + NSString *reference = [GTBranch.localNamePrefix stringByAppendingString:branchName]; + NSArray *branches = [repo branchesWithPrefix:reference error:NULL]; + expect(branches).notTo(beNil()); + expect(@(branches.count)).to(equal(@1)); + expect(((GTBranch *)branches[0]).shortName).to(equal(branchName)); + + return branches[0]; +};