From f18f37fc5bfb8c4a3e479c58af87200da335cf08 Mon Sep 17 00:00:00 2001 From: NikeKov Date: Thu, 15 Oct 2026 14:52:20 +0300 Subject: [PATCH 1/2] Mark branches checked out in other worktrees and offer to open them on checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git refuses to check out a branch that is already checked out in another worktree. GitUp now shows which branches are in that state and explains what to do instead of failing with a bare error. Added -[GCRepository lookupWorktreePathsForCheckedOutBranches:] which maps the branches checked out in other worktrees, the main one or linked ones, to their working directories. Worktrees with a detached HEAD or a missing directory are skipped. The result is cached in GCHistoryLocalBranch.worktreePath and included in the history references checksum, and GCLiveRepository now also watches ".git/worktrees/", so the graph updates when a worktree is added, removed or switched to another branch. The graph shows a "⧉ worktree: " line under the title of such a branch and a ⧉ marker next to its name in node labels and in the selected commit tooltip. Checking out such a branch shows an alert with Open Worktree, Show in Finder and Cancel instead of the "already checked out in another worktree" error. --- GitUpKit/Core/GCBranch-Tests.m | 55 +++++++++++++++++++ GitUpKit/Core/GCBranch.h | 2 + GitUpKit/Core/GCBranch.m | 48 ++++++++++++++++ GitUpKit/Core/GCHistory-Tests.m | 15 +++++ GitUpKit/Core/GCHistory.h | 1 + GitUpKit/Core/GCHistory.m | 26 +++++++++ GitUpKit/Core/GCLiveRepository-Tests.m | 46 ++++++++++++++++ GitUpKit/Core/GCLiveRepository.m | 18 ++++++ GitUpKit/Core/GCTestCase.h | 1 + GitUpKit/Core/GCTestCase.m | 8 +++ .../Extensions/GCLiveRepository+Utilities.m | 32 +++++++++++ GitUpKit/Interface/GIGraphView.m | 18 +++++- 12 files changed, 269 insertions(+), 1 deletion(-) diff --git a/GitUpKit/Core/GCBranch-Tests.m b/GitUpKit/Core/GCBranch-Tests.m index 41430f6b1..aaa2f2dc4 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 e5ab1bd1a..f7cb9fff6 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 507d7cee8..71d5f978e 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 e2a6beed2..29ebfb93e 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 1f055ad47..8194a88d9 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 e305fc070..c5a3cad4a 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 07680a343..660b65091 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 c3ebade77..c371b4c58 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 { @@ -142,6 +147,10 @@ - (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); } @@ -301,6 +310,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 397155f00..962c7362b 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 054da4eee..970d99f38 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 06d22b7b9..16627ca99 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 94478185e..41149dde4 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; } } From 69d8375b304fcdaf227166ce32ec035a01594822 Mon Sep 17 00:00:00 2001 From: NikeKov Date: Sat, 17 Oct 2026 13:55:53 +0300 Subject: [PATCH 2/2] Watch the common git directory of linked worktrees for worktree changes When GitUp opens a linked worktree, its git directory is ".git/worktrees//", so the HEAD of the main worktree and the git directories of the other linked worktrees were not watched and the worktree markers stayed stale until the next history update. GCLiveRepository now also watches the common git directory of a linked worktree and reloads the history when its root or "worktrees/" changes. Other changes in the common git directory are still ignored. --- GitUpKit/Core/GCLiveRepository.m | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/GitUpKit/Core/GCLiveRepository.m b/GitUpKit/Core/GCLiveRepository.m index c371b4c58..8fa704c9e 100644 --- a/GitUpKit/Core/GCLiveRepository.m +++ b/GitUpKit/Core/GCLiveRepository.m @@ -140,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]; @@ -154,6 +157,15 @@ - (void)_stream:(ConstFSEventStreamRef)stream didReceiveEvents:(size_t)numEvents } 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); + } } else { XLOG_DEBUG_UNREACHABLE(); } @@ -231,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;