Skip to content

Commit

Permalink
Create a test for rebaseBranch (with a basic conflict) (#989)
Browse files Browse the repository at this point in the history
* Create `rebaseBranch` tests and move `resolveConflictsWithResolver` method into GCLiveRepository category

* Reorganize rebase tests to include a simple rebase, one with conflict and one with multiple commits and conflicts (the last one fails with the current version)

* Add GCLiveRepository+Conflicts.m to the iOS GitUpKit target

* Fix release compilation by making GCLiveRepository+Conflicts.h public
  • Loading branch information
lapfelix authored Jun 29, 2024
1 parent aabbc8a commit 498bf96
Show file tree
Hide file tree
Showing 12 changed files with 394 additions and 59 deletions.
2 changes: 1 addition & 1 deletion GitUp/Application/Document.m
Original file line number Diff line number Diff line change
Expand Up @@ -1532,7 +1532,7 @@ - (void)conflictResolverViewControllerDidFinish:(GIConflictResolverViewControlle
_resolvingConflicts = 1;
}

#pragma mark - GIMergeConflictResolver
#pragma mark - GCMergeConflictResolver

- (BOOL)resolveMergeConflictsWithOurCommit:(GCCommit*)ourCommit theirCommit:(GCCommit*)theirCommit {
[self _enterResolveWithOurCommit:ourCommit theirCommit:theirCommit];
Expand Down
24 changes: 24 additions & 0 deletions GitUpKit/Core/GCLiveRepository+Conflicts.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// GCLiveRepository+Conflicts.h
// GitUpKit (macOS)
//
// Created by Felix Lapalme on 2024-04-13.
//

#import <GitUpKit/GitUpKit.h>

@protocol GCMergeConflictResolver <NSObject>
- (BOOL)resolveMergeConflictsWithOurCommit:(GCCommit*)ourCommit theirCommit:(GCCommit*)theirCommit;
@end

@interface GCLiveRepository (Conflicts)

- (GCCommit*)resolveConflictsWithResolver:(id<GCMergeConflictResolver>)resolver
index:(GCIndex*)index
ourCommit:(GCCommit*)ourCommit
theirCommit:(GCCommit*)theirCommit
parentCommits:(NSArray*)parentCommits
message:(NSString*)message
error:(NSError**)error;

@end
73 changes: 73 additions & 0 deletions GitUpKit/Core/GCLiveRepository+Conflicts.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// GCLiveRepository+Conflicts.m
// GitUpKit (macOS)
//
// Created by Felix Lapalme on 2024-04-13.
//

#import "GCLiveRepository+Conflicts.h"

#import "GCRepository+Utilities.h"
#import "GCRepository+HEAD.h"

@implementation GCLiveRepository (Conflicts)

- (GCCommit*)resolveConflictsWithResolver:(id<GCMergeConflictResolver>)resolver
index:(GCIndex*)index
ourCommit:(GCCommit*)ourCommit
theirCommit:(GCCommit*)theirCommit
parentCommits:(NSArray*)parentCommits
message:(NSString*)message
error:(NSError**)error {


// Save HEAD
GCCommit* headCommit;
GCLocalBranch* headBranch;
if (![self lookupHEADCurrentCommit:&headCommit branch:&headBranch error:error]) {
return nil;
}

// Detach HEAD to "ours" commit
if (![self checkoutCommit:parentCommits[0] options:0 error:error]) {
return nil;
}

// Check out index with conflicts
if (![self checkoutIndex:index withOptions:kGCCheckoutOption_UpdateSubmodulesRecursively error:error]) {
return nil;
}

// Have user resolve conflicts
BOOL resolved = [resolver resolveMergeConflictsWithOurCommit:ourCommit theirCommit:theirCommit];

// Unless user cancelled, create commit with "ours" and "theirs" parent commits (if applicable)
GCCommit* commit = nil;
if (resolved) {
if (![self syncIndexWithWorkingDirectory:error]) {
return nil;
}
commit = [self createCommitFromHEADAndOtherParent:(parentCommits.count > 1 ? parentCommits[1] : nil) withMessage:message error:error];
if (commit == nil) {
return nil;
}
}

// Restore HEAD
if ((headBranch && ![self setHEADToReference:headBranch error:error]) || (!headBranch && ![self setDetachedHEADToCommit:headCommit error:error])) {
return nil;
}
if (![self forceCheckoutHEAD:YES error:error]) {
return nil;
}

// Check if user cancelled
if (!resolved) {
GC_SET_USER_CANCELLED_ERROR();
return nil;
}

return commit;
}

@end
221 changes: 221 additions & 0 deletions GitUpKit/Core/GCLiveRepository-Tests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
//
// GCLiveRepository-Tests.m
// Tests
//
// Created by Felix Lapalme on 2024-04-12.
//

#import <XCTest/XCTest.h>
#import "GCTestCase.h"
#import "GCHistory+Rewrite.h"
#import "GCRepository+Index.h"
#import "GCLiveRepository+Conflicts.h"
#import "GIViewController+Utilities.h"

// block based object that conforms to GCMergeConflictResolver
@interface GCBlockConflictResolver : NSObject <GCMergeConflictResolver>
@property(nonatomic, copy) BOOL (^resolveBlock)(GCCommit* ourCommit, GCCommit* theirCommit);

- (instancetype)initWithBlock:(BOOL (^)(GCCommit* ourCommit, GCCommit* theirCommit))resolveBlock;
@end

@implementation GCBlockConflictResolver

- (instancetype)initWithBlock:(BOOL (^)(GCCommit* ourCommit, GCCommit* theirCommit))resolveBlock {
self = [super init];
if (self) {
self.resolveBlock = resolveBlock;
}
return self;
}

- (BOOL)resolveMergeConflictsWithOurCommit:(GCCommit*)ourCommit theirCommit:(GCCommit*)theirCommit {
return self.resolveBlock(ourCommit, theirCommit);
}

@end

@implementation GCEmptyLiveRepositoryTestCase (GCLiveRepository)

- (void)testRebase {
// Initial setup: create a base commit on master.
GCCommit* baseCommit = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\n" message:@"Base commit"];

// Create a new branch from the base commit.
XCTAssertTrue([self.liveRepository createLocalBranchFromCommit:baseCommit withName:@"other_branch" force:NO error:NULL]);

GCLocalBranch* masterBranch = [self.liveRepository findLocalBranchWithName:@"master" error:NULL];
GCLocalBranch* otherBranch = [self.liveRepository findLocalBranchWithName:@"other_branch" error:NULL];

XCTAssertTrue([self.liveRepository checkoutLocalBranch:masterBranch options:0 error:NULL]);
GCCommit* masterCommit = [self makeCommitWithUpdatedFileAtPath:@"shared2.txt" string:@"new text file\n" message:@"Master commit 1"];
XCTAssertNotNil(masterCommit);

NSError* error;

// Make a commit on the other branch that also modifies the same shared file.
XCTAssertTrue([self.liveRepository checkoutLocalBranch:otherBranch options:0 error:NULL]);
GCCommit* otherCommit1 = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\nAnd a new line\n" message:@"Other commit 1"];
XCTAssertNotNil(otherCommit1);

XCTAssertNil(error);

[self rebaseAndSolveConflictsWithBaseCommit:baseCommit expectedCommitTotalCount:3];

// Verify the results of the rebase.
NSString* finalContent = [NSString stringWithContentsOfFile:[self.liveRepository.workingDirectoryPath stringByAppendingPathComponent:@"shared.txt"] encoding:NSUTF8StringEncoding error:NULL];
XCTAssertEqualObjects(finalContent, @"Initial content\nAnd a new line\n");
}

- (void)testRebaseConflict {
// Initial setup: create a base commit on master.
GCCommit* baseCommit = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\n" message:@"Base commit"];

// Create a new branch from the base commit.
XCTAssertTrue([self.liveRepository createLocalBranchFromCommit:baseCommit withName:@"other_branch" force:NO error:NULL]);

GCLocalBranch* masterBranch = [self.liveRepository findLocalBranchWithName:@"master" error:NULL];
GCLocalBranch* otherBranch = [self.liveRepository findLocalBranchWithName:@"other_branch" error:NULL];

XCTAssertTrue([self.liveRepository checkoutLocalBranch:masterBranch options:0 error:NULL]);
GCCommit* masterCommit = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\nMaster modification 1\n" message:@"Master commit 1"];
XCTAssertNotNil(masterCommit);

NSError* error;

// Make a commit on the other branch that also modifies the same shared file.
XCTAssertTrue([self.liveRepository checkoutLocalBranch:otherBranch options:0 error:NULL]);
GCCommit* otherCommit1 = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\nOther modification 1\n" message:@"Other commit 1"];
XCTAssertNotNil(otherCommit1);

XCTAssertNil(error);

[self rebaseAndSolveConflictsWithBaseCommit:baseCommit expectedCommitTotalCount:3];

// Verify the results of the rebase.
NSString* finalContent = [NSString stringWithContentsOfFile:[self.liveRepository.workingDirectoryPath stringByAppendingPathComponent:@"shared.txt"] encoding:NSUTF8StringEncoding error:NULL];
XCTAssertEqualObjects(finalContent, @"Conflict resolved\n", @"File content should reflect resolved conflict.");
}

- (void)testMultipleCommitsRebaseWithConflict {
// Initial setup: create a base commit on master.
GCCommit* baseCommit = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\n" message:@"Base commit"];

// Create a new branch from the base commit.
XCTAssertTrue([self.liveRepository createLocalBranchFromCommit:baseCommit withName:@"other_branch" force:NO error:NULL]);

GCLocalBranch* masterBranch = [self.liveRepository findLocalBranchWithName:@"master" error:NULL];
GCLocalBranch* otherBranch = [self.liveRepository findLocalBranchWithName:@"other_branch" error:NULL];

XCTAssertTrue([self.liveRepository checkoutLocalBranch:masterBranch options:0 error:NULL]);
GCCommit* masterCommit = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\nMaster modification 1\n" message:@"Master commit 1"];
XCTAssertNotNil(masterCommit);

// create other changed files
GCCommit* masterCommit2 = [self makeCommitWithUpdatedFileAtPath:@"shared2.txt" string:@"Initial content\nMaster modification 2\n" message:@"Master commit 2"];
XCTAssertNotNil(masterCommit2);

GCCommit* masterCommit3 = [self makeCommitWithUpdatedFileAtPath:@"shared3.txt" string:@"Initial content\nMaster modification 3\n" message:@"Master commit 3"];
XCTAssertNotNil(masterCommit3);

NSError* error;

// Make a commit on the other branch that also modifies the same shared file.
XCTAssertTrue([self.liveRepository checkoutLocalBranch:otherBranch options:0 error:NULL]);
GCCommit* otherCommit1 = [self makeCommitWithUpdatedFileAtPath:@"shared.txt" string:@"Initial content\nOther modification 1\n" message:@"Other commit 1"];
XCTAssertNotNil(otherCommit1);

// create other changed files
GCCommit* otherCommit2 = [self makeCommitWithUpdatedFileAtPath:@"shared4.txt" string:@"Initial content\nOther modification 2\n" message:@"Other commit 2"];
XCTAssertNotNil(otherCommit2);

GCCommit* otherCommit3 = [self makeCommitWithUpdatedFileAtPath:@"shared5.txt" string:@"Initial content\nOther modification 3\n" message:@"Other commit 3"];
XCTAssertNotNil(otherCommit3);

XCTAssertNil(error);

[self rebaseAndSolveConflictsWithBaseCommit:baseCommit expectedCommitTotalCount:7];

// Verify the results of the rebase.
NSString* finalContent = [NSString stringWithContentsOfFile:[self.liveRepository.workingDirectoryPath stringByAppendingPathComponent:@"shared.txt"] encoding:NSUTF8StringEncoding error:NULL];
XCTAssertEqualObjects(finalContent, @"Conflict resolved\n", @"File content should reflect resolved conflict.");
}

- (void)rebaseAndSolveConflictsWithBaseCommit:(GCCommit*)baseCommit expectedCommitTotalCount:(int)expectedTotalCommitCount {
NSError* error;
GCHistory* history = [self.liveRepository loadHistoryUsingSorting:kGCHistorySorting_ReverseChronological error:&error];
GCHistoryLocalBranch* otherHistoryBranch = history.HEADBranch;
GCHistoryLocalBranch* masterHistoryBranch = [history.localBranches filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(GCHistoryLocalBranch* _Nullable localBranch, NSDictionary<NSString*, id>* _Nullable bindings) {
return [localBranch.name isEqualToString:@"master"];
}]]
.firstObject;
GCHistoryCommit* otherHistoryCommit = otherHistoryBranch.tipCommit;
GCHistoryCommit* masterHistoryCommit = masterHistoryBranch.tipCommit;

GCCommit* foundBaseCommit = [self.liveRepository findMergeBaseForCommits:@[ otherHistoryCommit, masterHistoryCommit ] error:&error];
XCTAssertNotNil(foundBaseCommit);
GCHistoryCommit* fromCommit = [history historyCommitForCommit:baseCommit];

// Attempt to rebase the other branch onto master.
NSError* rebaseError = NULL;
[self.liveRepository suspendHistoryUpdates];

[self.liveRepository setStatusMode:kGCLiveRepositoryStatusMode_Normal];

__block GCCommit* newCommit = nil;
[self.liveRepository setUndoActionName:NSLocalizedString(@"Rebase test", nil)];

BOOL rebaseSuccess = [self.liveRepository performReferenceTransformWithReason:@"rebase_branch"
argument:masterHistoryBranch.name
error:&rebaseError
usingBlock:^GCReferenceTransform*(GCLiveRepository* repository, NSError** outError1) {
return [history rebaseBranch:otherHistoryBranch
fromCommit:fromCommit
ontoCommit:masterHistoryCommit
conflictHandler:^GCCommit*(GCIndex* index, GCCommit* ourCommit, GCCommit* theirCommit, NSArray* parentCommits, NSString* message, NSError** outError2) {
GCBlockConflictResolver* blockResolver = [[GCBlockConflictResolver alloc] initWithBlock:^BOOL(GCCommit* ourCommit, GCCommit* theirCommit) {
XCTAssertTrue([index hasConflicts]);
[index enumerateConflictsUsingBlock:^(GCIndexConflict* conflict, BOOL* stop) {
[self updateFileAtPath:conflict.path withString:@"Conflict resolved\n"];
NSError* conflictResolutionError;
[self.liveRepository resolveConflictAtPath:conflict.path error:&conflictResolutionError];
XCTAssertNil(conflictResolutionError);
}];

return YES;
}];

return [self.liveRepository resolveConflictsWithResolver:blockResolver
index:index
ourCommit:ourCommit
theirCommit:theirCommit
parentCommits:parentCommits
message:message
error:outError2];
}
newTipCommit:&newCommit
error:outError1];
}];
[self.liveRepository resumeHistoryUpdates];

XCTAssertNil(rebaseError, @"Rebase should not error out with proper conflict handling.");
XCTAssertTrue(rebaseSuccess, @"Rebase should complete successfully.");

// make sure the working directory is still clean
XCTAssertEqual(self.liveRepository.workingDirectoryStatus.deltas.count, 0);

//  count to make sure the number of parents makes sense
GCHistoryCommit* currentCommit = self.liveRepository.history.HEADCommit;
int numberOfCommits = 0;
while (true) {
numberOfCommits++;
currentCommit = currentCommit.parents.firstObject;
if (!currentCommit) {
break;
}
}

XCTAssertEqual(numberOfCommits, expectedTotalCommitCount);
}

@end
4 changes: 4 additions & 0 deletions GitUpKit/Core/GCTestCase.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
- (void)assertContentsOfFileAtPath:(NSString*)path equalsString:(NSString*)string;
@end

@interface GCEmptyLiveRepositoryTestCase : GCEmptyRepositoryTestCase<GCLiveRepositoryDelegate>
@property(nonatomic, readonly) GCLiveRepository* liveRepository;
@end

@interface GCEmptyRepositoryTests : GCEmptyRepositoryTestCase
@end

Expand Down
41 changes: 39 additions & 2 deletions GitUpKit/Core/GCTestCase.m
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ - (GCRepository*)createLocalRepositoryAtPath:(NSString*)path bare:(BOOL)bare {
XCTAssertTrue([[NSFileManager defaultManager] createDirectoryAtPath:configDirectory withIntermediateDirectories:YES attributes:nil error:NULL]);
NSString* configPath = [configDirectory stringByAppendingPathComponent:@"config"];
NSString* configString = @"[user]\n\
name = Bot\n\
email = bot@example.com\n\
name = Bot\n\
email = bot@example.com\n\
";
XCTAssertTrue([configString writeToFile:configPath atomically:YES encoding:NSASCIIStringEncoding error:NULL]);

Expand Down Expand Up @@ -194,6 +194,43 @@ - (void)assertContentsOfFileAtPath:(NSString*)path equalsString:(NSString*)strin
@implementation GCEmptyRepositoryTests
@end

@implementation GCEmptyLiveRepositoryTestCase

- (GCLiveRepository *)liveRepository {
return (GCLiveRepository *)self.repository;
}

- (GCRepository *)createLocalRepositoryAtPath:(NSString *)path bare:(BOOL)bare {
GCLiveRepository* repo = [[GCLiveRepository alloc] initWithNewLocalRepository:path bare:bare error:NULL];
XCTAssertNotNil(repo);

repo.delegate = self;

NSString* configDirectory = [[NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]] stringByAppendingPathComponent:@"git"];
XCTAssertTrue([[NSFileManager defaultManager] createDirectoryAtPath:configDirectory withIntermediateDirectories:YES attributes:nil error:NULL]);
NSString* configPath = [configDirectory stringByAppendingPathComponent:@"config"];
NSString* configString = @"[user]\n\
name = Bot\n\
email = bot@example.com\n\
";
XCTAssertTrue([configString writeToFile:configPath atomically:YES encoding:NSASCIIStringEncoding error:NULL]);

git_config* config;
XCTAssertEqual(git_config_new(&config), GIT_OK);
if (!repo.bare) {
XCTAssertEqual(git_config_add_file_ondisk(config, [[repo.repositoryPath stringByAppendingPathComponent:@"config"] fileSystemRepresentation], GIT_CONFIG_LEVEL_LOCAL, repo.private, true), GIT_OK);
}
XCTAssertEqual(git_config_add_file_ondisk(config, configPath.fileSystemRepresentation, GIT_CONFIG_LEVEL_APP, repo.private, true), GIT_OK);
git_repository_set_config(repo.private, config);
git_config_free(config);

objc_setAssociatedObject(repo, _associatedObjectKey, [configDirectory stringByDeletingLastPathComponent], OBJC_ASSOCIATION_RETAIN_NONATOMIC);

return repo;
}

@end

@implementation GCSingleCommitRepositoryTestCase

- (void)setUp {
Expand Down
Loading

0 comments on commit 498bf96

Please sign in to comment.