Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions GitUpKit/Core/GCBranch-Tests.m
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,59 @@ - (void)testBranches {
[self destroyLocalRepository:bare];
}

// A branch checked out in a linked worktree is reported with the directory of that worktree
- (void)testWorktreePathsMapBranchCheckedOutInLinkedWorktreeToItsDirectory {
NSString* worktreePath = [self temporaryWorktreePathWithName:@"IOS-7533"];
XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"add", worktreePath, @"topic", nil]));

NSDictionary* paths = [self.repository lookupWorktreePathsForCheckedOutBranches:NULL];

NSDictionary* expectedPaths = @{@"refs/heads/topic" : worktreePath};
XCTAssertEqualObjects(paths, expectedPaths);
}

// Seen from a linked worktree, the branches of the main worktree and of other linked worktrees are taken while its own branch is not
- (void)testWorktreePathsFromLinkedWorktreeListOtherWorktreesButNotItself {
XCTAssertNotNil([self.repository createLocalBranchFromCommit:self.commit1 withName:@"other" force:NO error:NULL]);
NSString* worktreePath = [self temporaryWorktreePathWithName:@"IOS-7533"];
XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"add", worktreePath, @"topic", nil]));
NSString* otherWorktreePath = [self temporaryWorktreePathWithName:@"IOS-7600"];
XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"add", otherWorktreePath, @"other", nil]));
GCRepository* worktreeRepository = [[GCRepository alloc] initWithExistingLocalRepository:worktreePath error:NULL];
XCTAssertNotNil(worktreeRepository);

NSDictionary* paths = [worktreeRepository lookupWorktreePathsForCheckedOutBranches:NULL];

NSDictionary* expectedPaths = @{
@"refs/heads/master" : [self.temporaryPath stringByResolvingSymlinksInPath],
@"refs/heads/other" : otherWorktreePath
};
XCTAssertEqualObjects(paths, expectedPaths);
}

// Only branches are marked: a worktree with a detached HEAD on a commit takes no branch
- (void)testWorktreePathsSkipWorktreeWithDetachedHEAD {
NSString* worktreePath = [self temporaryWorktreePathWithName:@"IOS-7533"];
XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"add", @"--detach", worktreePath, @"topic", nil]));

NSDictionary* paths = [self.repository lookupWorktreePathsForCheckedOutBranches:NULL];

XCTAssertEqualObjects(paths, @{});
}

// A worktree whose directory disappeared without "git worktree prune" no longer takes its branch and doesn't hide the other worktrees
- (void)testWorktreePathsSkipWorktreeWhoseDirectoryWasDeleted {
NSString* deletedWorktreePath = [self temporaryWorktreePathWithName:@"IOS-7533"];
XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"add", deletedWorktreePath, @"topic", nil]));
XCTAssertTrue([[NSFileManager defaultManager] removeItemAtPath:deletedWorktreePath error:NULL]);
XCTAssertNotNil([self.repository createLocalBranchFromCommit:self.commit1 withName:@"other" force:NO error:NULL]);
NSString* worktreePath = [self temporaryWorktreePathWithName:@"IOS-7600"];
XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"add", worktreePath, @"other", nil]));

NSDictionary* paths = [self.repository lookupWorktreePathsForCheckedOutBranches:NULL];

NSDictionary* expectedPaths = @{@"refs/heads/other" : worktreePath};
XCTAssertEqualObjects(paths, expectedPaths);
}

@end
2 changes: 2 additions & 0 deletions GitUpKit/Core/GCBranch.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@
- (GCBranch*)lookupUpstreamForLocalBranch:(GCLocalBranch*)branch error:(NSError**)error;
- (BOOL)setUpstream:(GCBranch*)upstreamBranch forLocalBranch:(GCLocalBranch*)branchBranch error:(NSError**)error; // git branch -u {remote}/{branch} {branch}
- (BOOL)unsetUpstreamForLocalBranch:(GCLocalBranch*)branchBranch error:(NSError**)error; // git branch --unset-upstream {branch}

- (NSDictionary*)lookupWorktreePathsForCheckedOutBranches:(NSError**)error; // git worktree list - Maps full names of branches checked out in *other* worktrees (main or linked) to their working directory paths
@end
48 changes: 48 additions & 0 deletions GitUpKit/Core/GCBranch.m
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,54 @@ - (BOOL)unsetUpstreamForLocalBranch:(GCLocalBranch*)branchBranch error:(NSError*
return YES;
}

#pragma mark - Worktrees

// Detached HEADs are ignored since the same commit can be checked out in any number of worktrees
static void _AddCheckedOutBranchForWorktree(git_repository* repository, NSMutableDictionary* paths) {
const char* workdir = git_repository_workdir(repository);
git_reference* headReference;
if (workdir && (git_reference_lookup(&headReference, repository, kHEADReferenceFullName) == GIT_OK)) {
if (git_reference_type(headReference) == GIT_REF_SYMBOLIC) {
[paths setObject:GCFileSystemPathFromGitPath(workdir).stringByResolvingSymlinksInPath forKey:[NSString stringWithUTF8String:git_reference_symbolic_target(headReference)]];
}
git_reference_free(headReference);
}
}

- (NSDictionary*)lookupWorktreePathsForCheckedOutBranches:(NSError**)error {
NSMutableDictionary* paths = [[NSMutableDictionary alloc] init];

// The main worktree is not listed by git_worktree_list() and only needs to be opened from a linked worktree
if (git_repository_is_worktree(self.private)) {
git_repository* mainRepository;
CALL_LIBGIT2_FUNCTION_RETURN(nil, git_repository_open, &mainRepository, git_repository_commondir(self.private));
_AddCheckedOutBranchForWorktree(mainRepository, paths);
git_repository_free(mainRepository);
}

git_strarray names;
CALL_LIBGIT2_FUNCTION_RETURN(nil, git_worktree_list, &names, self.private);
for (size_t i = 0; i < names.count; ++i) {
git_worktree* worktree;
if (git_worktree_lookup(&worktree, self.private, names.strings[i]) == GIT_OK) {
git_repository* worktreeRepository;
if ((git_worktree_validate(worktree) == GIT_OK) && (git_repository_open_from_worktree(&worktreeRepository, worktree) == GIT_OK)) { // Skip worktrees whose directory was deleted without "git worktree prune"
_AddCheckedOutBranchForWorktree(worktreeRepository, paths);
git_repository_free(worktreeRepository);
}
git_worktree_free(worktree);
}
}
git_strarray_free(&names);

// A linked worktree lists itself so leave out the branch checked out in this repository
NSString* workingDirectoryPath = self.workingDirectoryPath.stringByResolvingSymlinksInPath;
if (workingDirectoryPath) {
[paths removeObjectsForKeys:[paths allKeysForObject:workingDirectoryPath]];
}
return paths;
}

@end

@implementation GCRepository (GCBranch_Private)
Expand Down
15 changes: 15 additions & 0 deletions GitUpKit/Core/GCHistory-Tests.m
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,18 @@ - (void)testHistory_Empty {
}

@end

@implementation GCMultipleCommitsRepositoryTests (GCHistory)

// The graph marks a branch from its history object so the worktree that has the branch must be known there
- (void)testHistoryKnowsWorktreeOfBranchCheckedOutInLinkedWorktree {
NSString* worktreePath = [self temporaryWorktreePathWithName:@"IOS-7533"];
XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"add", worktreePath, @"topic", nil]));

GCHistory* history = [self.repository loadHistoryUsingSorting:kGCHistorySorting_None error:NULL];

XCTAssertNotNil(history);
XCTAssertEqualObjects([history historyLocalBranchWithName:@"topic"].worktreePath, worktreePath);
}

@end
1 change: 1 addition & 0 deletions GitUpKit/Core/GCHistory.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ typedef NS_ENUM(NSUInteger, GCHistorySorting) {
@interface GCHistoryLocalBranch : GCLocalBranch
@property(nonatomic, weak, readonly) GCHistoryCommit* tipCommit; // Cached at time of last history update and DOES NOT automatically update (use -lookupTipCommitForBranch:error: instead)
@property(nonatomic, weak, readonly) GCBranch* upstream; // Cached at time of last history update and DOES NOT automatically update (use -lookupUpstreamForLocalBranch:error: instead) - Will be a GCHistoryLocalBranch or GCHistoryRemoteBranch
@property(nonatomic, readonly) NSString* worktreePath; // Cached at time of last history update and DOES NOT automatically update (use -lookupWorktreePathsForCheckedOutBranches: instead) - Working directory of the other worktree this branch is checked out in or nil
@end

@interface GCHistoryRemoteBranch : GCRemoteBranch
Expand Down
26 changes: 26 additions & 0 deletions GitUpKit/Core/GCHistory.m
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,17 @@ - (void)dealloc {
@interface GCHistoryLocalBranch ()
@property(nonatomic, weak) GCHistoryCommit* tipCommit;
@property(nonatomic, weak) GCBranch* upstream;
@property(nonatomic, copy) NSString* worktreePath;
@end

@implementation GCHistoryLocalBranch

- (void)dealloc {
[_worktreePath release];

[super dealloc];
}

@end

@interface GCHistoryRemoteBranch ()
Expand Down Expand Up @@ -731,6 +739,16 @@ - (BOOL)_reloadHistory:(GCHistory*)history
}
}

// Find branches checked out in other worktrees
NSDictionary* worktreePaths = nil;
if (!snapshot) {
NSError* worktreeError;
worktreePaths = [self lookupWorktreePathsForCheckedOutBranches:&worktreeError];
if (worktreePaths == nil) {
XLOG_WARNING(@"Failed looking up worktrees of \"%@\": %@", self.repositoryPath, worktreeError); // Don't fail because of broken worktrees
}
}

// Initialize MD5
CC_MD5_CTX md5Context;
CC_MD5_CTX* md5ContextPtr = &md5Context; // Required for use within block
Expand Down Expand Up @@ -814,6 +832,7 @@ - (BOOL)_reloadHistory:(GCHistory*)history
GCCommit* referenceCommit = [[GCCommit alloc] initWithRepository:self commit:commit];
GCTagAnnotation* referenceAnnotation = tag ? [[GCTagAnnotation alloc] initWithRepository:self tag:tag] : nil;
git_buf upstreamName = {0};
NSString* worktreePath = nil;
if (git_reference_is_tag(reference)) {
referenceObject = [[GCHistoryTag alloc] initWithRepository:self reference:reference];
[tags addObject:referenceObject];
Expand All @@ -829,6 +848,9 @@ - (BOOL)_reloadHistory:(GCHistory*)history
if ((status != GIT_OK) && (status != GIT_ENOTFOUND)) {
LOG_LIBGIT2_ERROR(status); // Don't fail because of corrupted config
}

worktreePath = [worktreePaths objectForKey:referenceObject.fullName];
((GCHistoryLocalBranch*)referenceObject).worktreePath = worktreePath;
} else if (git_reference_is_remote(reference)) {
referenceObject = [[GCHistoryRemoteBranch alloc] initWithRepository:self reference:reference];
[remoteBranches addObject:referenceObject];
Expand All @@ -848,6 +870,10 @@ - (BOOL)_reloadHistory:(GCHistory*)history
if (upstreamName.ptr) {
CC_MD5_Update(md5ContextPtr, upstreamName.ptr, (CC_LONG)upstreamName.size);
}
if (worktreePath) {
const char* worktreePathString = worktreePath.fileSystemRepresentation;
CC_MD5_Update(md5ContextPtr, worktreePathString, (CC_LONG)strlen(worktreePathString));
}
}
git_buf_free(&upstreamName);
if (referenceAnnotation) {
Expand Down
46 changes: 46 additions & 0 deletions GitUpKit/Core/GCLiveRepository-Tests.m
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,49 @@ - (void)rebaseAndSolveConflictsWithBaseCommit:(GCCommit*)baseCommit expectedComm
}

@end

@implementation GCMultipleCommitsRepositoryTests (GCLiveRepository)

// Adds the worktree from this process because live repositories ignore file system events of their own process, so only changes made by other processes get observed
- (BOOL)addWorktreeFromThisProcessAtPath:(NSString*)path branchName:(NSString*)branchName {
if (![[NSFileManager defaultManager] createDirectoryAtPath:path.stringByDeletingLastPathComponent withIntermediateDirectories:YES attributes:nil error:NULL]) {
return NO;
}
git_reference* reference;
if (git_reference_lookup(&reference, self.repository.private, [@"refs/heads/" stringByAppendingString:branchName].UTF8String) != GIT_OK) {
return NO;
}
git_worktree_add_options options = GIT_WORKTREE_ADD_OPTIONS_INIT;
options.ref = reference;
git_worktree* worktree;
int status = git_worktree_add(&worktree, self.repository.private, path.lastPathComponent.UTF8String, path.fileSystemRepresentation, &options);
git_reference_free(reference);
if (status != GIT_OK) {
return NO;
}
git_worktree_free(worktree);
return YES;
}

// Removing one of several linked worktrees only changes ".git/worktrees/*" and the open repository must still drop the mark from the freed branch
- (void)testLiveHistoryUnmarksBranchWhenItsLinkedWorktreeIsRemoved {
XCTAssertNotNil([self.repository createLocalBranchFromCommit:self.commit1 withName:@"other" force:NO error:NULL]);
NSString* worktreePath = [self temporaryWorktreePathWithName:@"IOS-7533"];
XCTAssertTrue([self addWorktreeFromThisProcessAtPath:worktreePath branchName:@"topic"]);
NSString* removedWorktreePath = [self temporaryWorktreePathWithName:@"IOS-7600"];
XCTAssertTrue([self addWorktreeFromThisProcessAtPath:removedWorktreePath branchName:@"other"]);

GCLiveRepository* liveRepository = [[GCLiveRepository alloc] initWithExistingLocalRepository:self.repository.workingDirectoryPath error:NULL];
XCTAssertNotNil(liveRepository);
XCTAssertEqualObjects([liveRepository.history historyLocalBranchWithName:@"other"].worktreePath, removedWorktreePath);
XCTNSNotificationExpectation* historyUpdated = [[XCTNSNotificationExpectation alloc] initWithName:GCLiveRepositoryHistoryDidUpdateNotification object:liveRepository];
historyUpdated.expectationDescription = @"History of the open repository is updated after a linked worktree is removed";

XCTAssertNotNil(([self runGitCLTWithRepository:self.repository command:@"worktree", @"remove", removedWorktreePath, nil]));
[self waitForExpectations:@[ historyUpdated ] timeout:10];

XCTAssertNil([liveRepository.history historyLocalBranchWithName:@"other"].worktreePath);
XCTAssertEqualObjects([liveRepository.history historyLocalBranchWithName:@"topic"].worktreePath, worktreePath);
}

@end
38 changes: 36 additions & 2 deletions GitUpKit/Core/GCLiveRepository.m
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ @implementation GCLiveRepository {
int _gitDirectory;
FSEventStreamRef _gitDirectoryStream;
BOOL _gitDirectoryChanged;
BOOL _worktreesChanged;
FSEventStreamRef _workingDirectoryStream;
BOOL _workingDirectoryChanged;
CFRunLoopTimerRef _updateTimer; // Can't use a NSTimer because of retain-cycle
Expand Down Expand Up @@ -109,8 +110,12 @@ + (NSUInteger)allocatedCount {
- (void)_timer:(CFRunLoopTimerRef)timer {
if (timer == _updateTimer) {
[self _notifyWorkingDirectoryChanged:_workingDirectoryChanged gitDirectoryChanged:_gitDirectoryChanged];
if (_worktreesChanged && !_gitDirectoryChanged) { // A git directory change already reloads the history
[self _notifyWorktreesChanged];
}
_workingDirectoryChanged = NO;
_gitDirectoryChanged = NO;
_worktreesChanged = NO;
} else if (timer == _snapshotsTimer) {
[self _saveAutomaticSnapshotIfPending];
} else {
Expand All @@ -135,13 +140,29 @@ - (void)_stream:(ConstFSEventStreamRef)stream didReceiveEvents:(size_t)numEvents
const char* gitDirectoryPath = git_repository_path(self.private);
size_t length = strlen(gitDirectoryPath);
XLOG_DEBUG_CHECK(gitDirectoryPath[length - 1] == '/');
const char* commonDirectoryPath = git_repository_commondir(self.private);
size_t commonLength = strlen(commonDirectoryPath);
XLOG_DEBUG_CHECK(commonDirectoryPath[commonLength - 1] == '/');
if (stream == _gitDirectoryStream) {
if (!strncmp(path, gitDirectoryPath, length)) {
const char* subPath = &path[length];
if (!subPath[0] || !strncmp(subPath, "refs/", 5) || !strncmp(subPath, "logs/", 5)) { // We only care about ".git/", ".git/refs/*" and ".git/logs/*"
XLOG_DEBUG(@"Processed file system event for '%s'", path);
_gitDirectoryChanged = YES;
CFRunLoopTimerSetNextFireDate(_updateTimer, CFAbsoluteTimeGetCurrent() + kUpdateLatency);
} else if (!strncmp(subPath, "worktrees/", 10)) { // We also care about ".git/worktrees/*" to know which branches are checked out in linked worktrees
XLOG_DEBUG(@"Processed file system event for '%s'", path);
_worktreesChanged = YES;
CFRunLoopTimerSetNextFireDate(_updateTimer, CFAbsoluteTimeGetCurrent() + kUpdateLatency);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} else {
XLOG_DEBUG(@"Dropped file system event for '%s'", path);
}
} else if (!strncmp(path, commonDirectoryPath, commonLength)) { // Only reached for linked worktrees as the common git directory is the git directory otherwise
const char* subPath = &path[commonLength];
if (!subPath[0] || !strncmp(subPath, "worktrees/", 10)) { // We only care about the HEAD of the main worktree and ".git/worktrees/*" to know which branches are checked out in the other worktrees
XLOG_DEBUG(@"Processed file system event for '%s'", path);
_worktreesChanged = YES;
CFRunLoopTimerSetNextFireDate(_updateTimer, CFAbsoluteTimeGetCurrent() + kUpdateLatency);
} else {
XLOG_DEBUG(@"Dropped file system event for '%s'", path);
}
Expand Down Expand Up @@ -222,10 +243,14 @@ - (instancetype)initWithRepository:(git_repository*)repository error:(NSError**)
_updateTimer = CFRunLoopTimerCreate(kCFAllocatorDefault, HUGE_VALF, HUGE_VALF, 0, 0, _TimerCallBack, &context);
CFRunLoopAddTimer(CFRunLoopGetMain(), _updateTimer, kCFRunLoopCommonModes);

NSArray* paths = @[ path ];
if (git_repository_is_worktree(self.private)) { // The HEAD of the main worktree and the git directories of the other linked worktrees are in the common git directory
paths = @[ path, GCFileSystemPathFromGitPath(git_repository_commondir(self.private)) ];
}
FSEventStreamContext streamContext = {0, (__bridge void*)self, NULL, NULL, NULL};
_gitDirectoryStream = FSEventStreamCreate(kCFAllocatorDefault, _StreamCallback, &streamContext,
(__bridge CFArrayRef) @[ path ], kFSEventStreamEventIdSinceNow,
kFSLatency, kFSEventStreamCreateFlagIgnoreSelf); // This opens the path
(__bridge CFArrayRef)paths, kFSEventStreamEventIdSinceNow,
kFSLatency, kFSEventStreamCreateFlagIgnoreSelf); // This opens the paths
if (_gitDirectoryStream == NULL) {
XLOG_ERROR(@"Failed creating event stream at \"%@\"", path);
return nil;
Expand Down Expand Up @@ -301,6 +326,15 @@ - (void)_notifyWorkingDirectoryChanged:(BOOL)workingDirectoryChanged gitDirector
}
}

// Linked worktrees only affect which branches are checked out in them so there's no need for a full update
- (void)_notifyWorktreesChanged {
if (_historyUpdatesSuspended > 0) {
_historyUpdatePending = YES;
} else {
[self _updateHistory];
}
}

- (void)notifyRepositoryChanged {
[self _notifyWorkingDirectoryChanged:NO gitDirectoryChanged:YES];
}
Expand Down
1 change: 1 addition & 0 deletions GitUpKit/Core/GCTestCase.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- (void)assertGitCLTOutputEqualsString:(NSString*)string withRepository:(GCRepository*)repository command:(NSString*)command, ... NS_REQUIRES_NIL_TERMINATION;
- (void)assertGitCLTOutputContainsString:(NSString*)string withRepository:(GCRepository*)repository command:(NSString*)command, ... NS_REQUIRES_NIL_TERMINATION;
- (void)assertGitCLTOutputEndsWithString:(NSString*)string withRepository:(GCRepository*)repository command:(NSString*)command, ... NS_REQUIRES_NIL_TERMINATION;
- (NSString*)temporaryWorktreePathWithName:(NSString*)name; // Unique path free of symlinks whose parent directory is removed when the test ends
@end

@interface GCTests : GCTestCase
Expand Down
8 changes: 8 additions & 0 deletions GitUpKit/Core/GCTestCase.m
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ - (void)assertGitCLTOutputEndsWithString:(NSString*)string withRepository:(GCRep
XCTAssertTrue([result hasSuffix:string]);
}

- (NSString*)temporaryWorktreePathWithName:(NSString*)name {
NSString* directory = [[NSTemporaryDirectory() stringByResolvingSymlinksInPath] stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]];
[self addTeardownBlock:^{
[[NSFileManager defaultManager] removeItemAtPath:directory error:NULL];
}];
return [directory stringByAppendingPathComponent:name];
}

@end

@implementation GCTests
Expand Down
Loading
Loading