Skip to content
Merged
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
33 changes: 33 additions & 0 deletions GitUpKit/Core/GCRepository+HEAD-Tests.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions GitUpKit/Core/GCRepository+HEAD.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading