Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.TeamFoundation.SourceControl.WebApi;
using Microsoft.TeamFoundation.WorkItemTracking.Client;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MigrationTools.Tools;

namespace MigrationTools.Tests.Tools
{
[TestClass()]
public class TfsGitRepositoryInfoTests
{
/// <summary>
/// Helper method to create a mock ExternalLink using reflection to avoid complex TFS object model setup
/// </summary>
private ExternalLink CreateMockExternalLink(string uri)
{
// Try to create a mock RegisteredLinkType using reflection
// First try the two-parameter constructor
var linkTypeConstructor = typeof(RegisteredLinkType).GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
null,
new Type[] { typeof(string), typeof(string) },
null);

RegisteredLinkType linkType = null;

if (linkTypeConstructor != null)
{
linkType = (RegisteredLinkType)linkTypeConstructor.Invoke(new object[] { "MockLinkType", "Mock Link Type" });
}
else
{
// Try alternative constructors if the two-parameter one doesn't exist
var constructors = typeof(RegisteredLinkType).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);

foreach (var ctor in constructors)
{
var parameters = ctor.GetParameters();
if (parameters.Length == 1 && parameters[0].ParameterType == typeof(string))
{
linkType = (RegisteredLinkType)ctor.Invoke(new object[] { "MockLinkType" });
break;
}
else if (parameters.Length == 0)
{
linkType = (RegisteredLinkType)ctor.Invoke(new object[] { });
break;
}
}

// If still null, try to find any constructor with minimal parameters
if (linkType == null && constructors.Length > 0)
{
var simplestCtor = constructors[0];
var ctorParams = simplestCtor.GetParameters();
var args = new object[ctorParams.Length];
for (int i = 0; i < ctorParams.Length; i++)
{
args[i] = ctorParams[i].ParameterType.IsValueType ? Activator.CreateInstance(ctorParams[i].ParameterType) : null;
}
linkType = (RegisteredLinkType)simplestCtor.Invoke(args);
}
Comment on lines +54 to +64
}

if (linkType == null)
{
throw new InvalidOperationException("Could not create RegisteredLinkType instance via reflection. Available constructors may have changed.");
}

// Create ExternalLink using the mock link type
return new ExternalLink(linkType, uri);
}

[TestMethod(), TestCategory("L0")]
public void CreateFromGit_ValidLinkWithThreeParts_LegacyFormat_ShouldSucceed()
{
// Arrange - Legacy format with %2f encoding
var validLink = "vstfs:///Git/Commit/25f94570-e3e7-4b79-ad19-4b434787fd5a%2f50477259-3058-4dff-ba4c-e8c179ec5327%2f41dd2754058348d72a6417c0615c2543b9b55535";
var externalLink = CreateMockExternalLink(validLink);
var possibleRepos = new List<GitRepository>
{
new GitRepository { Id = Guid.Parse("50477259-3058-4dff-ba4c-e8c179ec5327") }
};

// Act
var result = TfsGitRepositoryInfo.CreateFromGit(externalLink, possibleRepos);

// Assert
Assert.IsNotNull(result);
Assert.AreEqual("50477259-3058-4dff-ba4c-e8c179ec5327", result.RepoID);
Assert.AreEqual("41dd2754058348d72a6417c0615c2543b9b55535", result.CommitID);
}

[TestMethod(), TestCategory("L0")]
public void CreateFromGit_ValidLinkWithMultipleCommitParts_LegacyFormat_ShouldSucceed()
{
// Arrange - Legacy format with extended commit ID
var validLink = "vstfs:///Git/Commit/25f94570-e3e7-4b79-ad19-4b434787fd5a%2f50477259-3058-4dff-ba4c-e8c179ec5327%2f41dd2754058348d72a6417c0615c2543b9b55535%2fextra%2fparts";
var externalLink = CreateMockExternalLink(validLink);
var possibleRepos = new List<GitRepository>
{
new GitRepository { Id = Guid.Parse("50477259-3058-4dff-ba4c-e8c179ec5327") }
};

// Act
var result = TfsGitRepositoryInfo.CreateFromGit(externalLink, possibleRepos);

// Assert
Assert.IsNotNull(result);
Assert.AreEqual("50477259-3058-4dff-ba4c-e8c179ec5327", result.RepoID);
Assert.AreEqual("41dd2754058348d72a6417c0615c2543b9b55535%2fextra%2fparts", result.CommitID);
}

[TestMethod(), TestCategory("L0")]
public void CreateFromGit_ValidLinkWithSlashes_NewFormat_ShouldSucceed()
{
// Arrange - New Azure DevOps format with forward slashes
var newFormatLink = "vstfs:///Git/Commit/MyProject/MyRepo/41dd2754058348d72a6417c0615c2543b9b55535";
var externalLink = CreateMockExternalLink(newFormatLink);
var possibleRepos = new List<GitRepository>
{
new GitRepository
{
Id = Guid.Parse("50477259-3058-4dff-ba4c-e8c179ec5327"),
Name = "MyRepo"
}
};

// Act
var result = TfsGitRepositoryInfo.CreateFromGit(externalLink, possibleRepos);

// Assert
Assert.IsNotNull(result);
Assert.AreEqual("50477259-3058-4dff-ba4c-e8c179ec5327", result.RepoID);
Assert.AreEqual("41dd2754058348d72a6417c0615c2543b9b55535", result.CommitID);
Assert.IsNotNull(result.GitRepo);
Assert.AreEqual("MyRepo", result.GitRepo.Name);
}

[TestMethod(), TestCategory("L0")]
public void CreateFromGit_InvalidLinkWithOnePart_LegacyFormat_ShouldReturnNull()
{
// Arrange - Legacy format with insufficient parts
var invalidLink = "vstfs:///Git/Commit/25f94570-e3e7-4b79-ad19-4b434787fd5a";
var externalLink = CreateMockExternalLink(invalidLink);
var possibleRepos = new List<GitRepository>();

// Act
var result = TfsGitRepositoryInfo.CreateFromGit(externalLink, possibleRepos);

// Assert
Assert.IsNull(result);
}

[TestMethod(), TestCategory("L0")]
public void CreateFromGit_InvalidLinkWithTwoParts_LegacyFormat_ShouldReturnNull()
{
// Arrange - Legacy format with only 2 parts (missing commitId)
var invalidLink = "vstfs:///Git/Commit/25f94570-e3e7-4b79-ad19-4b434787fd5a%2f50477259-3058-4dff-ba4c-e8c179ec5327";
var externalLink = CreateMockExternalLink(invalidLink);
var possibleRepos = new List<GitRepository>();

// Act
var result = TfsGitRepositoryInfo.CreateFromGit(externalLink, possibleRepos);

// Assert
Assert.IsNull(result);
}

[TestMethod(), TestCategory("L0")]
public void CreateFromGit_InvalidLinkWithTwoParts_NewFormat_ShouldReturnNull()
{
// Arrange - New format with insufficient parts
var invalidLink = "vstfs:///Git/Commit/MyProject/MyRepo";
var externalLink = CreateMockExternalLink(invalidLink);
var possibleRepos = new List<GitRepository>();

// Act
var result = TfsGitRepositoryInfo.CreateFromGit(externalLink, possibleRepos);

// Assert
Assert.IsNull(result);
}

[TestMethod(), TestCategory("L0")]
public void CreateFromGit_EmptyLink_ShouldReturnNull()
{
// Arrange
var emptyLink = "vstfs:///Git/Commit/";
var externalLink = CreateMockExternalLink(emptyLink);
var possibleRepos = new List<GitRepository>();

// Act
var result = TfsGitRepositoryInfo.CreateFromGit(externalLink, possibleRepos);

// Assert
Assert.IsNull(result);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,25 +91,73 @@ public static TfsGitRepositoryInfo CreateFromGit(ExternalLink gitExternalLink, I
string commitID;
string repoID;
GitRepository gitRepo;
//vstfs:///Git/Commit/25f94570-e3e7-4b79-ad19-4b434787fd5a%2f50477259-3058-4dff-ba4c-e8c179ec5327%2f41dd2754058348d72a6417c0615c2543b9b55535
string guidbits = gitExternalLink.LinkedArtifactUri.Substring(gitExternalLink.LinkedArtifactUri.LastIndexOf('/') + 1);
string[] bits = Regex.Split(guidbits, "%2f", RegexOptions.IgnoreCase);
repoID = bits[1];
if (bits.Count() >= 3)
//Legacy format: vstfs:///Git/Commit/25f94570-e3e7-4b79-ad19-4b434787fd5a%2f50477259-3058-4dff-ba4c-e8c179ec5327%2f41dd2754058348d72a6417c0615c2543b9b55535
//New format: vstfs:///Git/Commit/projectName/repoName/commitId

// Determine which format we're dealing with
if (gitExternalLink.LinkedArtifactUri.Contains("%2f", StringComparison.OrdinalIgnoreCase))
{
// Legacy format with %2f encoding
string guidbits = gitExternalLink.LinkedArtifactUri.Substring(gitExternalLink.LinkedArtifactUri.LastIndexOf('/') + 1);
string[] bits = Regex.Split(guidbits, "%2f", RegexOptions.IgnoreCase);

// Validate that we have at least 3 parts (projectId, repoId, commitId) for legacy format
if (bits.Length < 3)
{
Log.Warning("GitRepositoryInfo: Invalid Git external link format (legacy). Expected at least 3 parts separated by %2f, but got {count} parts. Link: {link}", bits.Length, gitExternalLink.LinkedArtifactUri);
return null;
}

// Legacy format: projectId%2frepoId%2fcommitId
repoID = bits[1];
commitID = $"{bits[2]}";
for (int i = 3; i < bits.Count(); i++)
{
commitID += $"%2f{bits[i]}";
}

gitRepo = (from g in possibleRepos
where string.Equals(g.Id.ToString(), repoID, StringComparison.OrdinalIgnoreCase)
select g).SingleOrDefault();
}
else
{
commitID = bits[2];
// New format with forward slashes: vstfs:///Git/Commit/projectName/repoName/commitId
const string prefix = "vstfs:///Git/Commit/";
if (!gitExternalLink.LinkedArtifactUri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
Log.Warning("GitRepositoryInfo: Invalid Git external link format (new). Link does not start with expected prefix. Link: {link}", gitExternalLink.LinkedArtifactUri);
return null;
}

string remainder = gitExternalLink.LinkedArtifactUri.Substring(prefix.Length);
string[] parts = remainder.Split('/');

// Validate that we have at least 3 parts (projectName, repoName, commitId)
if (parts.Length < 3)
{
Log.Warning("GitRepositoryInfo: Invalid Git external link format (new). Expected at least 3 parts separated by /, but got {count} parts. Link: {link}", parts.Length, gitExternalLink.LinkedArtifactUri);
return null;
Comment on lines +136 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty commit segments before returning repository info

When the input is vstfs:///Git/Commit/project/repo/, Split('/') produces three elements, so this validation passes and the method returns a matching repository with an empty CommitID. The enricher can consequently construct an invalid target link and remove the original source link; validate that the required project, repository, and commit segments are nonempty rather than checking only the array length.

Useful? React with 👍 / 👎.

}

// New format: projectName/repoName/commitId
string repoName = parts[1];
commitID = parts[2];

// Handle commit IDs that may contain additional slashes
for (int i = 3; i < parts.Length; i++)
{
commitID += $"/{parts[i]}";
}

// Look up repo by name instead of ID
gitRepo = (from g in possibleRepos
where string.Equals(g.Name, repoName, StringComparison.OrdinalIgnoreCase)
select g).SingleOrDefault();
Comment on lines +154 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the project segment to disambiguate repository names

For a cross-project link such as vstfs:///Git/Commit/OtherProject/CommonRepo/<sha>, this lookup ignores parts[0] and matches only CommonRepo. In TfsGitRepositoryTool.Enrich, the first call searches the configured project's repositories, so an identically named local repository is silently selected instead of OtherProject/CommonRepo; if that lookup misses, the retry against allSourceRepos can also make SingleOrDefault() throw when multiple projects contain that name. Filter using both the project and repository segments so the migrated link cannot target the wrong repository.

Useful? React with 👍 / 👎.


repoID = gitRepo?.Id.ToString();
Comment on lines +143 to +158
}
gitRepo =
(from g in possibleRepos where string.Equals(g.Id.ToString(), repoID, StringComparison.OrdinalIgnoreCase) select g)
.SingleOrDefault();

return new TfsGitRepositoryInfo(commitID, repoID, gitRepo);
}

Expand Down
Loading