diff --git a/LibGit2Sharp.Tests/CloneFixture.cs b/LibGit2Sharp.Tests/CloneFixture.cs index d291ddcdc..25c6f5da2 100644 --- a/LibGit2Sharp.Tests/CloneFixture.cs +++ b/LibGit2Sharp.Tests/CloneFixture.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using LibGit2Sharp.Handlers; using LibGit2Sharp.Tests.TestHelpers; using Xunit; using Xunit.Extensions; @@ -242,5 +244,243 @@ public void CloningWithoutUrlThrows() Assert.Throws(() => Repository.Clone(null, scd.DirectoryPath)); } + + /// + /// Private helper to record the callbacks that were called as part of a clone. + /// + private class CloneCallbackInfo + { + /// + /// Was checkout progress called. + /// + public bool CheckoutProgressCalled { get; set; } + + /// + /// The reported remote URL. + /// + public string RemoteUrl { get; set; } + + /// + /// Was remote ref update called. + /// + public bool RemoteRefUpdateCalled { get; set; } + + /// + /// Was the transition callback called when starting + /// work on this repository. + /// + public bool StartingWorkInRepositoryCalled { get; set; } + + /// + /// Was the transition callback called when finishing + /// work on this repository. + /// + public bool FinishedWorkInRepositoryCalled { get; set; } + + /// + /// The reported recursion depth. + /// + public int RecursionDepth { get; set; } + } + + [Fact] + public void CanRecursivelyCloneSubmodules() + { + var uri = new Uri(Path.GetFullPath(SandboxSubmoduleSmallTestRepo())); + var scd = BuildSelfCleaningDirectory(); + string relativeSubmodulePath = "submodule_target_wd"; + + // Construct the expected URL the submodule will clone from. + string expectedSubmoduleUrl = Path.Combine(Path.GetDirectoryName(uri.AbsolutePath), relativeSubmodulePath); + expectedSubmoduleUrl = expectedSubmoduleUrl.Replace('\\', '/'); + + Dictionary callbacks = new Dictionary(); + + CloneCallbackInfo currentEntry = null; + bool unexpectedOrderOfCallbacks = false; + + CheckoutProgressHandler checkoutProgressHandler = (x, y, z) => + { + if (currentEntry != null) + { + currentEntry.CheckoutProgressCalled = true; + } + else + { + // Should not be called if there is not a current + // callbackInfo entry. + unexpectedOrderOfCallbacks = true; + } + }; + + UpdateTipsHandler remoteRefUpdated = (x, y, z) => + { + if (currentEntry != null) + { + currentEntry.RemoteRefUpdateCalled = true; + } + else + { + // Should not be called if there is not a current + // callbackInfo entry. + unexpectedOrderOfCallbacks = true; + } + + return true; + }; + + RepositoryOperationStarting repositoryOperationStarting = (x) => + { + if (currentEntry != null) + { + // Should not be called if there is a current + // callbackInfo entry. + unexpectedOrderOfCallbacks = true; + } + + currentEntry = new CloneCallbackInfo(); + currentEntry.StartingWorkInRepositoryCalled = true; + currentEntry.RecursionDepth = x.RecursionDepth; + currentEntry.RemoteUrl = x.RemoteUrl; + callbacks.Add(x.RepositoryPath, currentEntry); + + return true; + }; + + RepositoryOperationCompleted repositoryOperationCompleted = (x) => + { + if (currentEntry != null) + { + currentEntry.FinishedWorkInRepositoryCalled = true; + currentEntry = null; + } + else + { + // Should not be called if there is not a current + // callbackInfo entry. + unexpectedOrderOfCallbacks = true; + } + }; + + CloneOptions options = new CloneOptions() + { + RecurseSubmodules = true, + OnCheckoutProgress = checkoutProgressHandler, + OnUpdateTips = remoteRefUpdated, + RepositoryOperationStarting = repositoryOperationStarting, + RepositoryOperationCompleted = repositoryOperationCompleted, + }; + + string clonedRepoPath = Repository.Clone(uri.AbsolutePath, scd.DirectoryPath, options); + string workDirPath; + + using(Repository repo = new Repository(clonedRepoPath)) + { + workDirPath = repo.Info.WorkingDirectory.TrimEnd(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }); + } + + // Verification: + // Verify that no callbacks were called in an unexpected order. + Assert.False(unexpectedOrderOfCallbacks); + + Dictionary expectedCallbackInfo = new Dictionary(); + expectedCallbackInfo.Add(workDirPath, new CloneCallbackInfo() + { + RecursionDepth = 0, + RemoteUrl = uri.AbsolutePath, + StartingWorkInRepositoryCalled = true, + FinishedWorkInRepositoryCalled = true, + CheckoutProgressCalled = true, + RemoteRefUpdateCalled = true, + }); + + expectedCallbackInfo.Add(Path.Combine(workDirPath, relativeSubmodulePath), new CloneCallbackInfo() + { + RecursionDepth = 1, + RemoteUrl = expectedSubmoduleUrl, + StartingWorkInRepositoryCalled = true, + FinishedWorkInRepositoryCalled = true, + CheckoutProgressCalled = true, + RemoteRefUpdateCalled = true, + }); + + // Callbacks for each expected repository that is cloned + foreach (KeyValuePair kvp in expectedCallbackInfo) + { + CloneCallbackInfo entry = null; + Assert.True(callbacks.TryGetValue(kvp.Key, out entry), string.Format("{0} was not found in callbacks.", kvp.Key)); + + Assert.Equal(kvp.Value.RemoteUrl, entry.RemoteUrl); + Assert.Equal(kvp.Value.RecursionDepth, entry.RecursionDepth); + Assert.Equal(kvp.Value.StartingWorkInRepositoryCalled, entry.StartingWorkInRepositoryCalled); + Assert.Equal(kvp.Value.FinishedWorkInRepositoryCalled, entry.FinishedWorkInRepositoryCalled); + Assert.Equal(kvp.Value.CheckoutProgressCalled, entry.CheckoutProgressCalled); + Assert.Equal(kvp.Value.RemoteRefUpdateCalled, entry.RemoteRefUpdateCalled); + } + + // Verify the state of the submodule + using(Repository repo = new Repository(clonedRepoPath)) + { + var sm = repo.Submodules[relativeSubmodulePath]; + Assert.True(sm.RetrieveStatus().HasFlag(SubmoduleStatus.InWorkDir | + SubmoduleStatus.InConfig | + SubmoduleStatus.InIndex | + SubmoduleStatus.InHead)); + + Assert.NotNull(sm.HeadCommitId); + Assert.Equal("480095882d281ed676fe5b863569520e54a7d5c0", sm.HeadCommitId.Sha); + + Assert.False(repo.RetrieveStatus().IsDirty); + } + } + + [Fact] + public void CanCancelRecursiveClone() + { + var uri = new Uri(Path.GetFullPath(SandboxSubmoduleSmallTestRepo())); + var scd = BuildSelfCleaningDirectory(); + string relativeSubmodulePath = "submodule_target_wd"; + + int cancelDepth = 0; + + RepositoryOperationStarting repositoryOperationStarting = (x) => + { + return !(x.RecursionDepth >= cancelDepth); + }; + + CloneOptions options = new CloneOptions() + { + RecurseSubmodules = true, + RepositoryOperationStarting = repositoryOperationStarting, + }; + + Assert.Throws(() => + Repository.Clone(uri.AbsolutePath, scd.DirectoryPath, options)); + + // Cancel after super repository is cloned, but before submodule is cloned. + cancelDepth = 1; + + string clonedRepoPath = null; + + try + { + Repository.Clone(uri.AbsolutePath, scd.DirectoryPath, options); + } + catch(RecurseSubmodulesException ex) + { + Assert.NotNull(ex.InnerException); + Assert.Equal(typeof(UserCancelledException), ex.InnerException.GetType()); + clonedRepoPath = ex.InitialRepositoryPath; + } + + // Verify that the submodule was not initialized. + using(Repository repo = new Repository(clonedRepoPath)) + { + var submoduleStatus = repo.Submodules[relativeSubmodulePath].RetrieveStatus(); + Assert.Equal(SubmoduleStatus.InConfig | SubmoduleStatus.InHead | SubmoduleStatus.InIndex | SubmoduleStatus.WorkDirUninitialized, + submoduleStatus); + + } + } } } diff --git a/LibGit2Sharp.Tests/TestHelpers/BaseFixture.cs b/LibGit2Sharp.Tests/TestHelpers/BaseFixture.cs index a7cc1a585..26a27b27c 100644 --- a/LibGit2Sharp.Tests/TestHelpers/BaseFixture.cs +++ b/LibGit2Sharp.Tests/TestHelpers/BaseFixture.cs @@ -40,7 +40,7 @@ static BaseFixture() public static string SubmoduleTestRepoWorkingDirPath { get; private set; } private static string SubmoduleTargetTestRepoWorkingDirPath { get; set; } private static string AssumeUnchangedRepoWorkingDirPath { get; set; } - private static string SubmoduleSmallTestRepoWorkingDirPath { get; set; } + public static string SubmoduleSmallTestRepoWorkingDirPath { get; set; } public static DirectoryInfo ResourcesDirectory { get; private set; } @@ -71,7 +71,7 @@ private static void SetUpTestEnvironment() SubmoduleTestRepoWorkingDirPath = Path.Combine(sourceRelativePath, "submodule_wd"); SubmoduleTargetTestRepoWorkingDirPath = Path.Combine(sourceRelativePath, "submodule_target_wd"); AssumeUnchangedRepoWorkingDirPath = Path.Combine(sourceRelativePath, "assume_unchanged_wd"); - SubmoduleSmallTestRepoWorkingDirPath = Path.Combine(ResourcesDirectory.FullName, "submodule_small_wd"); + SubmoduleSmallTestRepoWorkingDirPath = Path.Combine(sourceRelativePath, "submodule_small_wd"); } private static bool IsFileSystemCaseSensitiveInternal() @@ -159,8 +159,7 @@ public string SandboxAssumeUnchangedTestRepo() public string SandboxSubmoduleSmallTestRepo() { - var submoduleTarget = Path.Combine(ResourcesDirectory.FullName, "submodule_target_wd"); - var path = Sandbox(SubmoduleSmallTestRepoWorkingDirPath, submoduleTarget); + var path = Sandbox(SubmoduleSmallTestRepoWorkingDirPath, SubmoduleTargetTestRepoWorkingDirPath); Directory.CreateDirectory(Path.Combine(path, "submodule_target_wd")); return path; diff --git a/LibGit2Sharp/CloneOptions.cs b/LibGit2Sharp/CloneOptions.cs index 98f529ef6..6b264e8a4 100644 --- a/LibGit2Sharp/CloneOptions.cs +++ b/LibGit2Sharp/CloneOptions.cs @@ -33,6 +33,11 @@ public CloneOptions() /// public string BranchName { get; set; } + /// + /// Recursively clone submodules. + /// + public bool RecurseSubmodules { get; set; } + /// /// Handler for checkout progress information. /// diff --git a/LibGit2Sharp/Core/NativeMethods.cs b/LibGit2Sharp/Core/NativeMethods.cs index a8fb1eacf..83d8980fb 100644 --- a/LibGit2Sharp/Core/NativeMethods.cs +++ b/LibGit2Sharp/Core/NativeMethods.cs @@ -1361,6 +1361,12 @@ internal static extern int git_submodule_lookup( RepositorySafeHandle repo, [MarshalAs(UnmanagedType.CustomMarshaler, MarshalCookie = UniqueId.UniqueIdentifier, MarshalTypeRef = typeof(StrictFilePathMarshaler))] FilePath name); + [DllImport(libgit2)] + internal static extern int git_submodule_resolve_url( + GitBuf buf, + RepositorySafeHandle repo, + [MarshalAs(UnmanagedType.CustomMarshaler, MarshalCookie = UniqueId.UniqueIdentifier, MarshalTypeRef = typeof(StrictUtf8Marshaler))] string url); + [DllImport(libgit2)] internal static extern int git_submodule_update( SubmoduleSafeHandle sm, diff --git a/LibGit2Sharp/Core/Proxy.cs b/LibGit2Sharp/Core/Proxy.cs index ab2f7eadc..936724629 100644 --- a/LibGit2Sharp/Core/Proxy.cs +++ b/LibGit2Sharp/Core/Proxy.cs @@ -2811,6 +2811,18 @@ public static SubmoduleSafeHandle git_submodule_lookup(RepositorySafeHandle repo } } + public static string git_submodule_resolve_url(RepositorySafeHandle repo, string url) + { + using (ThreadAffinity()) + using (var buf = new GitBuf()) + { + int res = NativeMethods.git_submodule_resolve_url(buf, repo, url); + + Ensure.ZeroResult(res); + return LaxUtf8Marshaler.FromNative(buf.ptr); + } + } + public static ICollection git_submodule_foreach(RepositorySafeHandle repo, Func resultSelector) { return git_foreach(resultSelector, c => NativeMethods.git_submodule_foreach(repo, (x, y, p) => c(x, y, p), IntPtr.Zero)); diff --git a/LibGit2Sharp/FetchOptionsBase.cs b/LibGit2Sharp/FetchOptionsBase.cs index c2f2aeb44..7ad3673e0 100644 --- a/LibGit2Sharp/FetchOptionsBase.cs +++ b/LibGit2Sharp/FetchOptionsBase.cs @@ -33,5 +33,15 @@ internal FetchOptionsBase() /// Handler to generate for authentication. /// public CredentialsHandler CredentialsProvider { get; set; } + + /// + /// Starting to operate on a new repository. + /// + public RepositoryOperationStarting RepositoryOperationStarting { get; set; } + + /// + /// Completed operating on the current repository. + /// + public RepositoryOperationCompleted RepositoryOperationCompleted { get; set; } } } diff --git a/LibGit2Sharp/Handlers.cs b/LibGit2Sharp/Handlers.cs index 74c746f1a..196b438fd 100644 --- a/LibGit2Sharp/Handlers.cs +++ b/LibGit2Sharp/Handlers.cs @@ -1,4 +1,5 @@ -namespace LibGit2Sharp.Handlers +using System; +namespace LibGit2Sharp.Handlers { /// /// Delegate definition to handle Progress callback. @@ -37,6 +38,21 @@ /// True to continue, false to cancel. public delegate bool TransferProgressHandler(TransferProgress progress); + /// + /// Delegate definition to indicate that a repository is about to be operated on. + /// (In the context of a recursive operation). + /// + /// Context on the repository that is being operated on. + /// true to continue, false to cancel. + public delegate bool RepositoryOperationStarting(RepositoryOperationContext context); + + /// + /// Delegate definition to indicate that an operation is done in a repository. + /// (In the context of a recursive operation). + /// + /// Context on the repository that is being operated on. + public delegate void RepositoryOperationCompleted(RepositoryOperationContext context); + /// /// Delegate definition for callback reporting push network progress. /// diff --git a/LibGit2Sharp/LibGit2Sharp.csproj b/LibGit2Sharp/LibGit2Sharp.csproj index 3ca4a3892..cda459548 100644 --- a/LibGit2Sharp/LibGit2Sharp.csproj +++ b/LibGit2Sharp/LibGit2Sharp.csproj @@ -107,6 +107,7 @@ + @@ -123,6 +124,7 @@ + diff --git a/LibGit2Sharp/RecurseSubmodulesException.cs b/LibGit2Sharp/RecurseSubmodulesException.cs new file mode 100644 index 000000000..e643df648 --- /dev/null +++ b/LibGit2Sharp/RecurseSubmodulesException.cs @@ -0,0 +1,38 @@ +using System; +using System.Runtime.Serialization; + +namespace LibGit2Sharp +{ + /// + /// The exception that is thrown when an error is encountered while recursing + /// through submodules. The inner exception contains the exception that was + /// initially thrown while operating on the submodule. + /// + [Serializable] + public class RecurseSubmodulesException : LibGit2SharpException + { + /// + /// Initializes a new instance of the class. + /// + public RecurseSubmodulesException() + { + } + + /// + /// The path to the initial repository the operation was run on. + /// + public virtual string InitialRepositoryPath { get; private set; } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. If the parameter is not a null reference, the current exception is raised in a catch block that handles the inner exception. + /// The path to the initial repository the operation was performed on. + public RecurseSubmodulesException(string message, Exception innerException, string initialRepositoryPath) + : base(message, innerException) + { + InitialRepositoryPath = initialRepositoryPath; + } + } +} diff --git a/LibGit2Sharp/Repository.cs b/LibGit2Sharp/Repository.cs index f5712c37e..2ec130c08 100644 --- a/LibGit2Sharp/Repository.cs +++ b/LibGit2Sharp/Repository.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using LibGit2Sharp.Core; using LibGit2Sharp.Core.Handles; +using LibGit2Sharp.Handlers; namespace LibGit2Sharp { @@ -544,6 +545,13 @@ public static string Discover(string startingPath) /// /// Clone with specified options. /// + /// This exception is thrown when there + /// is an error is encountered while recursively cloning submodules. The inner exception + /// will contain the original exception. The initially cloned repository would + /// be reported through the + /// property." + /// Exception thrown when the cancelling + /// the clone of the initial repository." /// URI for the remote repository /// Local path to clone into /// controlling clone behavior @@ -556,6 +564,19 @@ public static string Clone(string sourceUrl, string workdirPath, options = options ?? new CloneOptions(); + // context variable that contains information on the repository that + // we are cloning. + var context = new RepositoryOperationContext(Path.GetFullPath(workdirPath), sourceUrl); + + // Notify caller that we are starting to work with the current repository. + bool continueOperation = OnRepositoryOperationStarting(options.RepositoryOperationStarting, + context); + + if(!continueOperation) + { + throw new UserCancelledException("Clone cancelled by the user."); + } + using (GitCheckoutOptsWrapper checkoutOptionsWrapper = new GitCheckoutOptsWrapper(options)) { var gitCheckoutOptions = checkoutOptionsWrapper.Options; @@ -571,19 +592,137 @@ public static string Clone(string sourceUrl, string workdirPath, RemoteCallbacks = gitRemoteCallbacks, }; + string clonedRepoPath; + try { cloneOpts.CheckoutBranch = StrictUtf8Marshaler.FromManaged(options.BranchName); using (RepositorySafeHandle repo = Proxy.git_clone(sourceUrl, workdirPath, ref cloneOpts)) { - return Proxy.git_repository_path(repo).Native; + clonedRepoPath = Proxy.git_repository_path(repo).Native; } } finally { EncodingMarshaler.Cleanup(cloneOpts.CheckoutBranch); } + + // Notify caller that we are done with the current repository. + OnRepositoryOperationCompleted(options.RepositoryOperationCompleted, + context); + + // Recursively clone submodules if requested. + try + { + RecursivelyCloneSubmodules(options, clonedRepoPath, 1); + } + catch (Exception ex) + { + throw new RecurseSubmodulesException( + "The top level repository was cloned, but there was an error cloning its submodules.", + ex, + clonedRepoPath); + } + + return clonedRepoPath; + } + } + + /// + /// Recursively clone submodules if directed to do so by the clone options. + /// + /// Options controlling clone behavior. + /// Path of the parent repository. + /// The current depth of the recursion. + private static void RecursivelyCloneSubmodules(CloneOptions options, string repoPath, int recursionDepth) + { + if (options.RecurseSubmodules) + { + List submodules = new List(); + + using (Repository repo = new Repository(repoPath)) + { + SubmoduleUpdateOptions updateOptions = new SubmoduleUpdateOptions() + { + Init = true, + CredentialsProvider = options.CredentialsProvider, + OnCheckoutProgress = options.OnCheckoutProgress, + OnProgress = options.OnProgress, + OnTransferProgress = options.OnTransferProgress, + OnUpdateTips = options.OnUpdateTips, + }; + + string parentRepoWorkDir = repo.Info.WorkingDirectory; + + // Iterate through the submodules (where the submodule is in the index), + // and clone them. + foreach (var sm in repo.Submodules.Where(sm => sm.RetrieveStatus().HasFlag(SubmoduleStatus.InIndex))) + { + string fullSubmodulePath = Path.Combine(parentRepoWorkDir, sm.Path); + + // Resolve the URL in the .gitmodule file to the one actually used + // to clone + string resolvedUrl = Proxy.git_submodule_resolve_url(repo.Handle, sm.Url); + + var context = new RepositoryOperationContext(fullSubmodulePath, + resolvedUrl, + parentRepoWorkDir, + sm.Name, + recursionDepth); + + bool continueOperation = OnRepositoryOperationStarting(options.RepositoryOperationStarting, + context); + + if (!continueOperation) + { + throw new UserCancelledException("Recursive clone of submodules was cancelled."); + } + + repo.Submodules.Update(sm.Name, updateOptions); + + OnRepositoryOperationCompleted(options.RepositoryOperationCompleted, + context); + + submodules.Add(Path.Combine(repo.Info.WorkingDirectory, sm.Path)); + } + } + + // If we are continuing the recursive operation, then + // recurse into nested submodules. + // Check submodules to see if they have their own submodules. + foreach (string submodule in submodules) + { + RecursivelyCloneSubmodules(options, submodule, recursionDepth + 1); + } + } + } + + /// + /// If a callback has been provided to notify callers that we are + /// either starting to work on a repository. + /// + /// The callback to notify change. + /// Context of the repository this operation affects. + /// true to continue the operation, false to cancel. + private static bool OnRepositoryOperationStarting(RepositoryOperationStarting repositoryChangedCallback, + RepositoryOperationContext context) + { + bool continueOperation = true; + if (repositoryChangedCallback != null) + { + continueOperation = repositoryChangedCallback(context); + } + + return continueOperation; + } + + private static void OnRepositoryOperationCompleted(RepositoryOperationCompleted repositoryChangedCallback, + RepositoryOperationContext context) + { + if (repositoryChangedCallback != null) + { + repositoryChangedCallback(context); } } diff --git a/LibGit2Sharp/RepositoryOperationContext.cs b/LibGit2Sharp/RepositoryOperationContext.cs new file mode 100644 index 000000000..466deb4cb --- /dev/null +++ b/LibGit2Sharp/RepositoryOperationContext.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace LibGit2Sharp +{ + /// + /// Class to convey information about the repository that is being operated on + /// for operations that can recurse into submodules. + /// + public class RepositoryOperationContext + { + /// + /// Needed for mocking. + /// + protected RepositoryOperationContext() + { } + + /// + /// Constructor suitable for use on the repository the main + /// operation is being run on (i.e. the super project, not a submodule). + /// + /// The path of the repository being operated on. + /// The URL that this operation will download from. + internal RepositoryOperationContext(string repositoryPath, string remoteUrl) + : this(repositoryPath, remoteUrl, string.Empty, string.Empty, 0) + { + } + + /// + /// Constructor suitable for use on the sub repositories. + /// + /// The path of the repository being operated on. + /// The URL that this operation will download from. + /// The path to the super repository. + /// The logical name of this submodule. + /// The depth of this sub repository from the original super repository. + internal RepositoryOperationContext(string repositoryPath, + string remoteUrl, + string parentRepositoryPath, + string submoduleName, int recursionDepth) + { + RepositoryPath = repositoryPath; + RemoteUrl = remoteUrl; + ParentRepositoryPath = parentRepositoryPath; + SubmoduleName = submoduleName; + RecursionDepth = recursionDepth; + } + + /// + /// If this is a submodule repository, the full path to the parent + /// repository. If this is not a submodule repository, then + /// this is empty. + /// + public virtual string ParentRepositoryPath { get; private set; } + + /// + /// The recursion depth for the current repository being operated on + /// with respect to the repository the original operation was run + /// against. The initial repository has a recursion depth of 0, + /// the 1st level of subrepositories has a recursion depth of 1. + /// + public virtual int RecursionDepth { get; private set; } + + /// + /// The remote URL this operation will work against, if any. + /// + public virtual string RemoteUrl { get; private set; } + + /// + /// Full path of the repository. + /// + public virtual string RepositoryPath { get; private set; } + + /// + /// The submodule's logical name in the parent repository, if this is a + /// submodule repository. If this is not a submodule repository, then + /// this is empty. + /// + public virtual string SubmoduleName { get; private set; } + } +}