diff --git a/GitUpKit/Core/GCBranch-Tests.m b/GitUpKit/Core/GCBranch-Tests.m index 41430f6b..aaa2f2dc 100644 --- a/GitUpKit/Core/GCBranch-Tests.m +++ b/GitUpKit/Core/GCBranch-Tests.m @@ -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 diff --git a/GitUpKit/Core/GCBranch.h b/GitUpKit/Core/GCBranch.h index e5ab1bd1..f7cb9fff 100644 --- a/GitUpKit/Core/GCBranch.h +++ b/GitUpKit/Core/GCBranch.h @@ -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 diff --git a/GitUpKit/Core/GCBranch.m b/GitUpKit/Core/GCBranch.m index 507d7cee..71d5f978 100644 --- a/GitUpKit/Core/GCBranch.m +++ b/GitUpKit/Core/GCBranch.m @@ -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) diff --git a/GitUpKit/Core/GCHistory-Tests.m b/GitUpKit/Core/GCHistory-Tests.m index e2a6beed..29ebfb93 100644 --- a/GitUpKit/Core/GCHistory-Tests.m +++ b/GitUpKit/Core/GCHistory-Tests.m @@ -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 diff --git a/GitUpKit/Core/GCHistory.h b/GitUpKit/Core/GCHistory.h index 1f055ad4..8194a88d 100644 --- a/GitUpKit/Core/GCHistory.h +++ b/GitUpKit/Core/GCHistory.h @@ -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 diff --git a/GitUpKit/Core/GCHistory.m b/GitUpKit/Core/GCHistory.m index e305fc07..c5a3cad4 100644 --- a/GitUpKit/Core/GCHistory.m +++ b/GitUpKit/Core/GCHistory.m @@ -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 () @@ -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 @@ -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]; @@ -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]; @@ -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) { diff --git a/GitUpKit/Core/GCLiveRepository-Tests.m b/GitUpKit/Core/GCLiveRepository-Tests.m index 07680a34..660b6509 100644 --- a/GitUpKit/Core/GCLiveRepository-Tests.m +++ b/GitUpKit/Core/GCLiveRepository-Tests.m @@ -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 diff --git a/GitUpKit/Core/GCLiveRepository.m b/GitUpKit/Core/GCLiveRepository.m index c3ebade7..8fa704c9 100644 --- a/GitUpKit/Core/GCLiveRepository.m +++ b/GitUpKit/Core/GCLiveRepository.m @@ -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 @@ -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 { @@ -135,6 +140,9 @@ - (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]; @@ -142,6 +150,19 @@ - (void)_stream:(ConstFSEventStreamRef)stream didReceiveEvents:(size_t)numEvents 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); + } 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); } @@ -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; @@ -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]; } diff --git a/GitUpKit/Core/GCTestCase.h b/GitUpKit/Core/GCTestCase.h index 397155f0..962c7362 100644 --- a/GitUpKit/Core/GCTestCase.h +++ b/GitUpKit/Core/GCTestCase.h @@ -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 diff --git a/GitUpKit/Core/GCTestCase.m b/GitUpKit/Core/GCTestCase.m index 054da4ee..970d99f3 100644 --- a/GitUpKit/Core/GCTestCase.m +++ b/GitUpKit/Core/GCTestCase.m @@ -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 diff --git a/GitUpKit/Extensions/GCLiveRepository+Utilities.m b/GitUpKit/Extensions/GCLiveRepository+Utilities.m index 06d22b7b..16627ca9 100644 --- a/GitUpKit/Extensions/GCLiveRepository+Utilities.m +++ b/GitUpKit/Extensions/GCLiveRepository+Utilities.m @@ -67,8 +67,40 @@ - (void)checkoutRemoteBranch:(GCHistoryRemoteBranch*)remoteBranch window:(NSWind } } +// Git doesn't allow checking out the same branch in several worktrees so offer to go to the worktree that has it instead +- (void)_presentAlertForBranch:(GCHistoryLocalBranch*)branch checkedOutInWorktreeAtPath:(NSString*)path window:(NSWindow*)window { + NSAlert* alert = [[NSAlert alloc] init]; + alert.messageText = [NSString stringWithFormat:NSLocalizedString(@"The branch \"%@\" is already checked out in another worktree", nil), branch.name]; + alert.informativeText = [NSString stringWithFormat:NSLocalizedString(@"A branch can only be checked out in one worktree at a time. This one is checked out in \"%@\".", nil), path.stringByAbbreviatingWithTildeInPath]; + [alert addButtonWithTitle:NSLocalizedString(@"Open Worktree", nil)]; + [alert addButtonWithTitle:NSLocalizedString(@"Show in Finder", nil)]; + [alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)]; + alert.type = kGIAlertType_Note; + [alert beginSheetModalForWindow:window + completionHandler:^(NSModalResponse returnCode) { + NSURL* url = [NSURL fileURLWithPath:path isDirectory:YES]; + if (returnCode == NSAlertFirstButtonReturn) { + [[NSDocumentController sharedDocumentController] openDocumentWithContentsOfURL:url + display:YES + completionHandler:^(NSDocument* document, BOOL documentWasAlreadyOpen, NSError* openError) { + if (!document) { + [[NSDocumentController sharedDocumentController] presentError:openError]; + } + }]; + } else if (returnCode == NSAlertSecondButtonReturn) { + [[NSWorkspace sharedWorkspace] activateFileViewerSelectingURLs:@[ url ]]; + } + }]; +} + // This will preemptively abort on conflicts in workdir or index so there's no need to require a clean repo - (void)_checkoutLocalBranch:(GCHistoryLocalBranch*)branch window:(NSWindow*)window { + NSString* worktreePath = [[self lookupWorktreePathsForCheckedOutBranches:NULL] objectForKey:branch.fullName]; // Don't rely on the history as changes in linked worktrees are not always observed + if (worktreePath) { + [self _presentAlertForBranch:branch checkedOutInWorktreeAtPath:worktreePath window:window]; + return; + } + NSError* error; [self setUndoActionName:[NSString stringWithFormat:NSLocalizedString(@"Checkout Branch \"%@\"", nil), branch.name]]; if (![self performOperationWithReason:@"checkout_branch" diff --git a/GitUpKit/Interface/GIGraphView.m b/GitUpKit/Interface/GIGraphView.m index 94478185..41149dde 100644 --- a/GitUpKit/Interface/GIGraphView.m +++ b/GitUpKit/Interface/GIGraphView.m @@ -70,6 +70,8 @@ #define kMaxBranchTitleWidth 250 +#define kWorktreeSymbol @"\u29C9" // Marks local branches checked out in another worktree + #define kScrollingInset kSpacingY #define CONVERT_X(x) (kSpacingX + (x) * kSpacingX) @@ -1077,6 +1079,14 @@ static void _DrawBranchTitle(CGContextRef context, CGFloat x, CGFloat y, CGPoint [multilineTitle addAttribute:NSForegroundColorAttributeName value:darkColor range:upstreamNameRange]; } + NSString* worktreePath = localBranch.worktreePath; + if (worktreePath) { + NSString* worktreeName = worktreePath.lastPathComponent; + _AppendAttributedString((CFMutableAttributedStringRef)multilineTitle, [NSString stringWithFormat:@"%@ worktree: %@\n", kWorktreeSymbol, worktreeName], multilineTitleAttributes); + NSRange worktreeNameRange = NSMakeRange(multilineTitle.length - worktreeName.length - 1, worktreeName.length); // -1 to exclude '\n' + [multilineTitle addAttribute:NSForegroundColorAttributeName value:darkColor range:worktreeNameRange]; + } + _AppendAttributedString((CFMutableAttributedStringRef)multilineTitle, @"\n", nil); } @@ -1255,6 +1265,9 @@ static void _DrawNodeLabels(CGContextRef context, CGFloat x, CGFloat y, GINode* [label appendString:@", "]; } [label appendString:branch.name]; + if (branch.worktreePath) { + [label appendFormat:@" %@", kWorktreeSymbol]; + } ++index; } for (GCHistoryRemoteBranch* branch in commit.remoteBranches) { @@ -1428,11 +1441,14 @@ static void _DrawSelectedNode(CGContextRef context, CGFloat x, CGFloat y, GINode if (commit.localBranches.count) { _AppendAttributedString(string, @"\nLocal Branches: ", attributes2); NSUInteger index = 0; - for (GCHistoryTag* branch in commit.localBranches) { + for (GCHistoryLocalBranch* branch in commit.localBranches) { if (index > 0) { _AppendAttributedString(string, @", ", attributes1); } _AppendAttributedString(string, branch.name, attributes1); + if (branch.worktreePath) { + _AppendAttributedString(string, [NSString stringWithFormat:@" %@", kWorktreeSymbol], attributes1); + } ++index; } }