diff --git a/LibGit2Sharp.Tests/CheckoutFixture.cs b/LibGit2Sharp.Tests/CheckoutFixture.cs index cb28d8085..3b219f3ed 100644 --- a/LibGit2Sharp.Tests/CheckoutFixture.cs +++ b/LibGit2Sharp.Tests/CheckoutFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using LibGit2Sharp.Tests.TestHelpers; using Xunit; using Xunit.Extensions; @@ -10,6 +11,7 @@ public class CheckoutFixture : BaseFixture { private static readonly string originalFilePath = "a.txt"; private static readonly string originalFileContent = "Hello"; + private static readonly string alternateFileContent = "There again"; private static readonly string otherBranchName = "other"; [Theory] @@ -190,8 +192,16 @@ public void CheckoutUpdatesModifiedFilesInWorkingDirectory() } [Fact] - public void CanForcefullyCheckoutWithStagedChanges() + public void CanForcefullyCheckoutWithConflictingStagedChanges() { + // This test will check that we can checkout a branch that results + // in a conflict. Here is the high level steps of the test: + // 1) Create branch otherBranch from current commit in master, + // 2) Commit change to master + // 3) Switch to otherBranch + // 4) Create conflicting change + // 5) Forcefully checkout master + TemporaryCloneOfTestRepo path = BuildTemporaryCloneOfTestRepo(StandardTestRepoWorkingDirPath); using (var repo = new Repository(path.RepositoryPath)) @@ -200,31 +210,39 @@ public void CanForcefullyCheckoutWithStagedChanges() Branch master = repo.Branches["master"]; Assert.True(master.IsCurrentRepositoryHead); - // Set the working directory to the current head + // Set the working directory to the current head. ResetAndCleanWorkingDirectory(repo); Assert.False(repo.Index.RetrieveStatus().IsDirty); - // Add local change + // Create otherBranch from current Head. + repo.Branches.Add(otherBranchName, master.Tip); + + // Add change to master. string fullPath = Path.Combine(repo.Info.WorkingDirectory, fileFullPath); File.WriteAllText(fullPath, originalFileContent); repo.Index.Stage(fullPath); + repo.Commit("change in master", Constants.Signature, Constants.Signature); - // Verify working directory is now dirty - Assert.True(repo.Index.RetrieveStatus().IsDirty); + // Checkout otherBranch. + repo.Checkout(otherBranchName); + + // Add change to otherBranch. + File.WriteAllText(fullPath, alternateFileContent); + repo.Index.Stage(fullPath); - // And that the new file exists - Assert.True(File.Exists(fileFullPath)); + // Assert that normal checkout throws exception + // for the conflict. + Assert.Throws(() => repo.Checkout(master.CanonicalName)); - // Checkout with the force option - Branch targetBranch = repo.Branches["i-do-numbers"]; - targetBranch.Checkout(CheckoutOptions.Force, null); + // Checkout with force option should succeed. + repo.Checkout(master.CanonicalName, CheckoutOptions.Force, null); - // Assert that target branch is checked out - Assert.True(targetBranch.IsCurrentRepositoryHead); + // Assert that master branch is checked out. + Assert.True(repo.Branches["master"].IsCurrentRepositoryHead); - // And that staged change (add) is no longer preset - Assert.False(File.Exists(fileFullPath)); + // And that the current index is not dirty. + Assert.False(repo.Index.RetrieveStatus().IsDirty); } } @@ -254,11 +272,11 @@ public void CheckingOutWithMergeConflictsThrows() // Assert that checking out master throws // when there are unstaged commits - Assert.Throws(() => repo.Checkout("master")); + Assert.Throws(() => repo.Checkout("master")); // And when there are staged commits repo.Index.Stage(fullPath); - Assert.Throws(() => repo.Checkout("master")); + Assert.Throws(() => repo.Checkout("master")); } } @@ -267,8 +285,8 @@ public void CheckingOutInABareRepoThrows() { using (var repo = new Repository(BareTestRepoPath)) { - Assert.Throws(() => repo.Checkout(repo.Branches["refs/heads/test"])); - Assert.Throws(() => repo.Checkout("refs/heads/test")); + Assert.Throws(() => repo.Checkout(repo.Branches["refs/heads/test"])); + Assert.Throws(() => repo.Checkout("refs/heads/test")); } } @@ -325,6 +343,217 @@ public void CheckingOutThroughRepositoryCallsCheckoutProgress() } } + [Fact] + public void CheckoutRetainsUntrackedChanges() + { + SelfCleaningDirectory scd = BuildSelfCleaningDirectory(); + + using (var repo = Repository.Init(scd.DirectoryPath)) + { + PopulateBasicRepository(repo); + + // Generate an unstaged change. + string fullPathFileB = Path.Combine(repo.Info.WorkingDirectory, "b.txt"); + File.WriteAllText(fullPathFileB, alternateFileContent); + + // Verify that there is an untracked entry. + Assert.Equal(1, repo.Index.RetrieveStatus().Untracked.Count()); + Assert.Equal(FileStatus.Untracked, repo.Index.RetrieveStatus(fullPathFileB)); + + repo.Checkout(otherBranchName); + + // Verify untracked entry still exists. + Assert.Equal(1, repo.Index.RetrieveStatus().Untracked.Count()); + Assert.Equal(FileStatus.Untracked, repo.Index.RetrieveStatus(fullPathFileB)); + } + } + + [Fact] + public void ForceCheckoutRetainsUntrackedChanges() + { + SelfCleaningDirectory scd = BuildSelfCleaningDirectory(); + + using (var repo = Repository.Init(scd.DirectoryPath)) + { + PopulateBasicRepository(repo); + + // Generate an unstaged change. + string fullPathFileB = Path.Combine(repo.Info.WorkingDirectory, "b.txt"); + File.WriteAllText(fullPathFileB, alternateFileContent); + + // Verify that there is an untracked entry. + Assert.Equal(1, repo.Index.RetrieveStatus().Untracked.Count()); + Assert.Equal(FileStatus.Untracked, repo.Index.RetrieveStatus(fullPathFileB)); + + repo.Checkout(otherBranchName, CheckoutOptions.Force, null); + + // Verify untracked entry still exists. + Assert.Equal(1, repo.Index.RetrieveStatus().Untracked.Count()); + Assert.Equal(FileStatus.Untracked, repo.Index.RetrieveStatus(fullPathFileB)); + } + } + + [Fact] + public void CheckoutRetainsUnstagedChanges() + { + SelfCleaningDirectory scd = BuildSelfCleaningDirectory(); + + using (var repo = Repository.Init(scd.DirectoryPath)) + { + PopulateBasicRepository(repo); + + // Generate an unstaged change. + string fullPathFileA = Path.Combine(repo.Info.WorkingDirectory, originalFilePath); + File.WriteAllText(fullPathFileA, alternateFileContent); + + // Verify that there is a modified entry. + Assert.Equal(1, repo.Index.RetrieveStatus().Modified.Count()); + Assert.Equal(FileStatus.Modified, repo.Index.RetrieveStatus(fullPathFileA)); + + repo.Checkout(otherBranchName); + + // Verify modified entry still exists. + Assert.Equal(1, repo.Index.RetrieveStatus().Modified.Count()); + Assert.Equal(FileStatus.Modified, repo.Index.RetrieveStatus(fullPathFileA)); + } + } + + [Fact] + public void CheckoutRetainsStagedChanges() + { + SelfCleaningDirectory scd = BuildSelfCleaningDirectory(); + + using (var repo = Repository.Init(scd.DirectoryPath)) + { + PopulateBasicRepository(repo); + + // Generate a staged change. + string fullPathFileA = Path.Combine(repo.Info.WorkingDirectory, originalFilePath); + File.WriteAllText(fullPathFileA, alternateFileContent); + repo.Index.Stage(fullPathFileA); + + // Verify that there is a staged entry. + Assert.Equal(1, repo.Index.RetrieveStatus().Staged.Count()); + Assert.Equal(FileStatus.Staged, repo.Index.RetrieveStatus(fullPathFileA)); + + repo.Checkout(otherBranchName); + + // Verify staged entry still exists. + Assert.Equal(1, repo.Index.RetrieveStatus().Staged.Count()); + Assert.Equal(FileStatus.Staged, repo.Index.RetrieveStatus(fullPathFileA)); + } + } + + [Fact] + public void CheckoutRetainsIgnoredChanges() + { + SelfCleaningDirectory scd = BuildSelfCleaningDirectory(); + + using (var repo = Repository.Init(scd.DirectoryPath)) + { + PopulateBasicRepository(repo); + + // Create a bin directory. + string ignoredDirectoryPath = Path.Combine(repo.Info.WorkingDirectory, "bin"); + Directory.CreateDirectory(ignoredDirectoryPath); + + // Create file in ignored bin directory. + string ignoredFilePath = Path.Combine(repo.Info.WorkingDirectory, Path.Combine("bin", "some_ignored_file.txt")); + File.WriteAllText(ignoredFilePath, "hello from this ignored file."); + + // The following check does not report ignored entries... + // Assert.Equal(1, repo.Index.RetrieveStatus().Ignored.Count()); + + Assert.Equal(FileStatus.Ignored, repo.Index.RetrieveStatus(ignoredFilePath)); + + repo.Checkout(otherBranchName); + + // Verify that the ignored file still exists. + Assert.Equal(FileStatus.Ignored, repo.Index.RetrieveStatus(ignoredFilePath)); + Assert.True(File.Exists(ignoredFilePath)); + } + } + + [Fact] + public void ForceCheckoutRetainsIgnoredChanges() + { + SelfCleaningDirectory scd = BuildSelfCleaningDirectory(); + + using (var repo = Repository.Init(scd.DirectoryPath)) + { + PopulateBasicRepository(repo); + + // Create a bin directory. + string ignoredDirectoryPath = Path.Combine(repo.Info.WorkingDirectory, "bin"); + Directory.CreateDirectory(ignoredDirectoryPath); + + // Create file in ignored bin directory. + string ignoredFilePath = Path.Combine(repo.Info.WorkingDirectory, Path.Combine("bin", "some_ignored_file.txt")); + File.WriteAllText(ignoredFilePath, "hello from this ignored file."); + + // The following check does not report ignored entries... + // Assert.Equal(1, repo.Index.RetrieveStatus().Ignored.Count()); + + Assert.Equal(FileStatus.Ignored, repo.Index.RetrieveStatus(ignoredFilePath)); + + repo.Checkout(otherBranchName, CheckoutOptions.Force, null); + + // Verify that the ignored file still exists. + Assert.Equal(FileStatus.Ignored, repo.Index.RetrieveStatus(ignoredFilePath)); + Assert.True(File.Exists(ignoredFilePath)); + } + } + + [Fact] + public void CheckoutBranchSnapshot() + { + SelfCleaningDirectory scd = BuildSelfCleaningDirectory(); + + using (var repo = Repository.Init(scd.DirectoryPath)) + { + PopulateBasicRepository(repo); + + // Get the current status of master + // and the current tip. + Branch initial = repo.Branches["master"]; + Commit initialCommit = initial.Tip; + + // Add commit to master + string fullPath = Path.Combine(repo.Info.WorkingDirectory, originalFilePath); + File.WriteAllText(fullPath, "Update : hello from master branch!\n"); + repo.Index.Stage(fullPath); + repo.Commit("2nd commit", Constants.Signature, Constants.Signature); + + initial.Checkout(); + + // Head should point at initial commit. + Assert.Equal(repo.Head.Tip, initialCommit); + Assert.False(repo.Index.RetrieveStatus().IsDirty); + // Verify that HEAD is detached. + Assert.Equal(repo.Refs["HEAD"].TargetIdentifier, initial.Tip.Sha); + } + } + + [Fact] + public void CheckingOutRemoteBranchResultsInDetachedHead() + { + TemporaryCloneOfTestRepo path = BuildTemporaryCloneOfTestRepo(StandardTestRepoWorkingDirPath); + using (var repo = new Repository(path.RepositoryPath)) + { + Branch master = repo.Branches["master"]; + Assert.True(master.IsCurrentRepositoryHead); + + // Set the working directory to the current head + ResetAndCleanWorkingDirectory(repo); + + repo.Checkout("refs/remotes/origin/master"); + + // Verify that HEAD is detached. + Assert.Equal(repo.Refs["HEAD"].TargetIdentifier, repo.Branches["origin/master"].Tip.Sha); + + } + } + /// /// Helper method to populate a simple repository with /// a single file and two branches. @@ -332,9 +561,15 @@ public void CheckingOutThroughRepositoryCallsCheckoutProgress() /// Repository to populate private void PopulateBasicRepository(Repository repo) { - string fullPathFileA = Path.Combine(repo.Info.WorkingDirectory, "a.txt"); + // Generate a .gitignore file. + string gitIgnoreFilePath = Path.Combine(repo.Info.WorkingDirectory, ".gitignore"); + File.WriteAllText(gitIgnoreFilePath, "bin"); + repo.Index.Stage(gitIgnoreFilePath); + + string fullPathFileA = Path.Combine(repo.Info.WorkingDirectory, originalFilePath); File.WriteAllText(fullPathFileA, originalFileContent); repo.Index.Stage(fullPathFileA); + repo.Commit("Initial commit", Constants.Signature, Constants.Signature); repo.CreateBranch(otherBranchName); diff --git a/LibGit2Sharp.Tests/ResetHeadFixture.cs b/LibGit2Sharp.Tests/ResetHeadFixture.cs index d757633cd..f9b46c6c0 100644 --- a/LibGit2Sharp.Tests/ResetHeadFixture.cs +++ b/LibGit2Sharp.Tests/ResetHeadFixture.cs @@ -180,7 +180,7 @@ public void HardResetInABareRepositoryThrows() } } - [Fact(Skip = "Not working against current libgit2 version")] + [Fact] public void HardResetUpdatesTheContentOfTheWorkingDirectory() { var clone = BuildTemporaryCloneOfTestRepo(StandardTestRepoWorkingDirPath); diff --git a/LibGit2Sharp/Branch.cs b/LibGit2Sharp/Branch.cs index 8744b2ff1..da7e8dd73 100644 --- a/LibGit2Sharp/Branch.cs +++ b/LibGit2Sharp/Branch.cs @@ -189,7 +189,10 @@ public virtual Remote Remote } /// - /// Checkout this branch. + /// Checkout the tip commit of this object. + /// If this commit is the current tip of the branch, will checkout + /// the named branch. Otherwise, will checkout the tip commit as a + /// detached HEAD. /// public virtual void Checkout() { @@ -197,7 +200,10 @@ public virtual void Checkout() } /// - /// Checkout this branch with a callback for progress reporting. + /// Checkout the tip commit of this object + /// with a callback for progress reporting. If this commit is the + /// current tip of the branch, will checkout the named branch. Otherwise, + /// will checkout the tip commit as a detached HEAD. /// /// Options controlling checkout behavior. /// Callback method to report checkout progress updates through. diff --git a/LibGit2Sharp/Repository.cs b/LibGit2Sharp/Repository.cs index 3b9fa492e..072c105a9 100644 --- a/LibGit2Sharp/Repository.cs +++ b/LibGit2Sharp/Repository.cs @@ -491,15 +491,22 @@ public Branch Checkout(string committishOrBranchSpec, CheckoutOptions checkoutOp if (branch != null) { - return CheckoutInternal(branch.CanonicalName, checkoutOptions, onCheckoutProgress); + return Checkout(branch, checkoutOptions, onCheckoutProgress); } - var commitId = LookupCommit(committishOrBranchSpec).Id; - return CheckoutInternal(commitId.Sha, checkoutOptions, onCheckoutProgress); + Commit commit = LookupCommit(committishOrBranchSpec); + CheckoutTree(commit.Tree, checkoutOptions, onCheckoutProgress); + + // Update HEAD. + Refs.UpdateTarget("HEAD", commit.Id.Sha); + + return Head; } /// - /// Checkout the specified . + /// Checkout the tip commit of the specified object. If this commit is the + /// current tip of the branch, will checkout the named branch. Otherwise, will checkout the tip commit + /// as a detached HEAD. /// /// The to check out. /// controlling checkout behavior. @@ -509,7 +516,27 @@ public Branch Checkout(Branch branch, CheckoutOptions checkoutOptions, CheckoutP { Ensure.ArgumentNotNull(branch, "branch"); - return CheckoutInternal(branch.CanonicalName, checkoutOptions, onCheckoutProgress); + // Make sure this is not an unborn branch. + if (branch.Tip.Tree == null) + { + throw new Exception("branch tip is null, nothing to checkout."); + } + + CheckoutTree(branch.Tip.Tree, checkoutOptions, onCheckoutProgress); + + // Update HEAD. + if (!branch.IsRemote && + string.Equals(Refs[branch.CanonicalName].TargetIdentifier, branch.Tip.Id.Sha, + StringComparison.OrdinalIgnoreCase)) + { + Refs.UpdateTarget("HEAD", branch.CanonicalName); + } + else + { + Refs.UpdateTarget("HEAD", branch.Tip.Id.Sha); + } + + return Head; } /// @@ -520,47 +547,21 @@ public Branch Checkout(Branch branch, CheckoutOptions checkoutOptions, CheckoutP /// controlling checkout behavior. /// that checkout progress is reported through. /// The that was checked out. - private Branch CheckoutInternal(string commitIdOrCanonicalBranchName, CheckoutOptions checkoutOptions, CheckoutProgressHandler onCheckoutProgress) - { - if (Info.IsBare) - { - throw new InvalidOperationException("Checkout is not allowed in a bare repository."); - } - - // Unless the Force option is specified, - // check if the current index / working tree is in a state - // where we can checkout a new branch. - if ((checkoutOptions & CheckoutOptions.Force) != CheckoutOptions.Force) - { - RepositoryStatus repositoryStatus = Index.RetrieveStatus(); - if (repositoryStatus.IsDirty) - { - throw new MergeConflictException( - "There are changes to files in the working directory that would be overwritten by a checkout." + - "Please commit your changes before you switch branches."); - } - } - - // Update HEAD - Refs.UpdateTarget("HEAD", commitIdOrCanonicalBranchName); - - // Update the working directory - CheckoutHeadForce(onCheckoutProgress); - - return Head; - } - - private void CheckoutHeadForce(CheckoutProgressHandler onCheckoutProgress) + private void CheckoutTree(Tree tree, CheckoutOptions checkoutOptions, CheckoutProgressHandler onCheckoutProgress) { GitCheckoutOpts options = new GitCheckoutOpts { version = 1, - checkout_strategy = CheckoutStrategy.GIT_CHECKOUT_FORCE | - CheckoutStrategy.GIT_CHECKOUT_REMOVE_UNTRACKED, + checkout_strategy = CheckoutStrategy.GIT_CHECKOUT_SAFE, progress_cb = CheckoutCallbacks.GenerateCheckoutCallbacks(onCheckoutProgress) }; - Proxy.git_checkout_head(this.Handle, ref options); + if (checkoutOptions.HasFlag(CheckoutOptions.Force)) + { + options.checkout_strategy = CheckoutStrategy.GIT_CHECKOUT_FORCE; + } + + Proxy.git_checkout_tree(this.Handle, tree.Id, ref options); } ///