diff --git a/GitUpKit/Core/GCRepository+HEAD-Tests.m b/GitUpKit/Core/GCRepository+HEAD-Tests.m index fdfcc211..9b01e23d 100644 --- a/GitUpKit/Core/GCRepository+HEAD-Tests.m +++ b/GitUpKit/Core/GCRepository+HEAD-Tests.m @@ -194,4 +194,37 @@ - (void)testRestoreFileToParentVersion { [self assertContentsOfFileAtPath:@"new_file.txt" equalsString:@"New Content\n"]; } +// Regression test for issue #2713: checking out a branch that is already checked out in another +// worktree must fail *without* mutating the working directory or index. Previously the target tree +// was written to disk before the (failing) HEAD move, leaving staged "revert" debris behind. +- (void)testCheckoutBranchCheckedOutInLinkedWorktree { + // We start on master with a clean working directory. + [self assertContentsOfFileAtPath:@"hello_world.txt" equalsString:@"Hola Mundo!\n"]; + XCTAssertTrue([self.repository checkClean:0 error:NULL]); + + // Check out the topic branch in a linked worktree so it becomes off-limits in this one. + NSString* worktreePath = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; + NSString* worktreeOutput = [self runGitCLTWithRepository:self.repository command:@"worktree", @"add", worktreePath, @"topic", nil]; + XCTAssertNotNil(worktreeOutput); // nil means git exited non-zero, i.e. the worktree wasn't created + + // Switching to that same branch here must fail... + NSError* error; + XCTAssertFalse([self.repository checkoutLocalBranch:self.topicBranch options:0 error:&error]); + XCTAssertNotNil(error); + + // ...and must leave the working directory, index, and HEAD exactly as they were. + XCTAssertTrue([self.repository checkClean:0 error:NULL]); + [self assertContentsOfFileAtPath:@"hello_world.txt" equalsString:@"Hola Mundo!\n"]; + GCLocalBranch* branch; + GCCommit* head = [self.repository lookupHEAD:&branch error:NULL]; + XCTAssertEqualObjects(head, self.commit3); + XCTAssertEqualObjects(branch, self.masterBranch); + + // Re-checking-out the branch already at this HEAD must still be allowed. + XCTAssertTrue([self.repository checkoutLocalBranch:self.masterBranch options:kGCCheckoutOption_Force error:NULL]); + + // Clean up the linked worktree. + [[NSFileManager defaultManager] removeItemAtPath:worktreePath error:NULL]; +} + @end diff --git a/GitUpKit/Core/GCRepository+HEAD.m b/GitUpKit/Core/GCRepository+HEAD.m index 02896a46..101b0741 100644 --- a/GitUpKit/Core/GCRepository+HEAD.m +++ b/GitUpKit/Core/GCRepository+HEAD.m @@ -242,6 +242,16 @@ - (BOOL)updateSubmoduleReferenceAtPath:(NSString*)submodulePath toCommitSHA1:(NS // Because by default git_checkout_tree() assumes the baseline (i.e. expected content of workdir) is HEAD we must checkout first, then update HEAD - (BOOL)checkoutLocalBranch:(GCLocalBranch*)branch options:(GCCheckoutOptions)options error:(NSError**)error { + // Bail out *before* touching the working directory if the branch is checked out in another worktree. + // Otherwise _checkoutTreeForCommit: below writes the target tree into the index and working directory, then + // setHEADToReference: fails (git_repository_set_head() refuses to point at a branch that is the HEAD of a + // linked worktree), leaving those changes behind with no rollback. See issue #2713. git mirrors this check + // by only rejecting a *different* branch, so allow re-checking-out the branch that is already this HEAD. + git_reference* branchReference = branch.private; + if (git_reference_is_branch(branchReference) && !git_branch_is_head(branchReference) && git_branch_is_checked_out(branchReference)) { + GC_SET_GENERIC_ERROR(@"Branch '%@' is already checked out in another worktree", branch.name); + return NO; + } GCCommit* tipCommit = [self lookupTipCommitForBranch:branch error:error]; if (!tipCommit || ![self _checkoutTreeForCommit:tipCommit withBaseline:nil options:options error:error] || ![self setHEADToReference:branch error:error]) { return NO;