Skip to content

Commit 2ce6914

Browse files
authored
Merge pull request #31 from Techsola/directory_filter
Add ability to migrate only selected folders within the root folder
2 parents 8b06163 + a24e7a8 commit 2ce6914

File tree

4 files changed

+88
-20
lines changed

4 files changed

+88
-20
lines changed

Readme.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ Options:
4848
first changeset under the given source path.
4949
--max-changeset <max-changeset> The last changeset to migrate. Defaults to the most recent
5050
changeset under the given source path.
51+
--directories <directories> If this option is used, only the files within the specified
52+
directories (relative to the root path) will be migrated. If
53+
a file moves into this filter, the migrated result will
54+
appear with no prior history. If a file moves out of this
55+
filter, it will appear to be deleted. Wildcards are not
56+
currently supported.
5157
--root-path-changes <root-path-changes> Followed by one or more arguments with the format
5258
CS1234:$/New/Path. Changes the path that is mapped as the Git
5359
repository root to a new path during a specified changeset.

src/TfvcMigrator.Tests/EntryPointTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ await Program.Main(new[]
3030
"--out-dir", "somedir",
3131
"--min-changeset", "42",
3232
"--max-changeset", "43",
33+
"--directories", "a/", "b/c",
3334
"--root-path-changes", "CS1234:$/New/Path", "CS1235:$/Another/Path",
3435
"--pat", "somepat",
3536
}));
@@ -40,11 +41,12 @@ await Program.Main(new[]
4041
arguments[3].ShouldBe("somedir");
4142
arguments[4].ShouldBe(42);
4243
arguments[5].ShouldBe(43);
43-
arguments[6].ShouldBeOfType<ImmutableArray<RootPathChange>>().ShouldBe(new[]
44+
arguments[6].ShouldBe(new[] { "a/", "b/c" });
45+
arguments[7].ShouldBeOfType<ImmutableArray<RootPathChange>>().ShouldBe(new[]
4446
{
4547
new RootPathChange(1234, "$/New/Path"),
4648
new RootPathChange(1235, "$/Another/Path"),
4749
});
48-
arguments[7].ShouldBe("somepat");
50+
arguments[8].ShouldBe("somepat");
4951
}
5052
}

src/TfvcMigrator/PathUtils.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ public static bool Contains(string parentPath, string otherPath)
2020
&& otherPath[parentPath.Length] == '/' && otherPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
2121
}
2222

23-
public static bool IsOrContains(string parentPath, string otherPath)
23+
public static bool IsOrContains(ReadOnlySpan<char> parentPath, ReadOnlySpan<char> otherPath)
2424
{
25-
if (parentPath.EndsWith('/'))
25+
if (parentPath.EndsWith("/"))
2626
throw new ArgumentException("Path should not end with a trailing slash.", nameof(parentPath));
2727

28-
if (otherPath.EndsWith('/'))
28+
if (otherPath.EndsWith("/"))
2929
throw new ArgumentException("Path should not end with a trailing slash.", nameof(otherPath));
3030

3131
return otherPath.Length > parentPath.Length + 1

src/TfvcMigrator/Program.cs

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.CommandLine.NamingConventionBinder;
33
using System.Globalization;
44
using System.Text;
5+
using System.Text.RegularExpressions;
56
using LibGit2Sharp;
67
using Microsoft.TeamFoundation.SourceControl.WebApi;
78
using Microsoft.VisualStudio.Services.Common;
@@ -26,6 +27,14 @@ public static Task<int> Main(string[] args)
2627
new Option<string?>("--out-dir") { Description = "The directory path at which to create a new Git repository. Defaults to the last segment in the root path under the current directory." },
2728
new Option<int?>("--min-changeset") { Description = "The changeset defining the initial commit. Defaults to the first changeset under the given source path." },
2829
new Option<int?>("--max-changeset") { Description = "The last changeset to migrate. Defaults to the most recent changeset under the given source path." },
30+
new Option<ImmutableArray<string>>(
31+
"--directories",
32+
parseArgument: result => result.Tokens.Select(token => token.Value).ToImmutableArray())
33+
{
34+
Arity = ArgumentArity.OneOrMore,
35+
AllowMultipleArgumentsPerToken = true,
36+
Description = "If this option is used, only the files within the specified directories (relative to the root path) will be migrated. If a file moves into this filter, the migrated result will appear with no prior history. If a file moves out of this filter, it will appear to be deleted. Wildcards are not currently supported.",
37+
},
2938
new Option<ImmutableArray<RootPathChange>>(
3039
"--root-path-changes",
3140
parseArgument: result => result.Tokens.Select(token => ParseRootPathChange(token.Value)).ToImmutableArray())
@@ -65,13 +74,22 @@ public static async Task<int> MigrateAsync(
6574
string? outDir = null,
6675
int? minChangeset = null,
6776
int? maxChangeset = null,
77+
ImmutableArray<string> directories = default,
6878
ImmutableArray<RootPathChange> rootPathChanges = default,
6979
string? pat = null)
7080
{
7181
try
7282
{
83+
if (directories.IsDefault) directories = ImmutableArray<string>.Empty;
7384
if (rootPathChanges.IsDefault) rootPathChanges = ImmutableArray<RootPathChange>.Empty;
7485

86+
directories = ImmutableArray.CreateRange(
87+
directories,
88+
directory => '/' + Regex.Replace(directory.Trim('/', '\\'), @"[/\\]+", "/"));
89+
90+
if (directories is [] || directories.Contains("/"))
91+
directories = ImmutableArray.Create("");
92+
7593
var outputDirectory = Path.GetFullPath(
7694
new[] { outDir, PathUtils.GetLeaf(rootPath), projectCollectionUrl.Segments.LastOrDefault() }
7795
.First(name => !string.IsNullOrEmpty(name))!);
@@ -90,28 +108,38 @@ pat is not null
90108

91109
using var client = await connection.GetClientAsync<TfvcHttpClient>();
92110

93-
Console.WriteLine("Downloading changeset and label metadata...");
111+
Console.WriteLine("Downloading changeset metadata...");
94112

95-
var (changesets, allLabels) = await (
113+
var changesets = (await Task.WhenAll(directories.Select(directory =>
96114
client.GetChangesetsAsync(
97115
maxCommentLength: int.MaxValue,
98116
top: int.MaxValue,
99117
orderby: "ID asc",
100118
searchCriteria: new TfvcChangesetSearchCriteria
101119
{
102120
FollowRenames = true,
103-
ItemPath = rootPath,
121+
ItemPath = rootPath + directory,
104122
FromId = minChangeset ?? 0,
105123
ToId = maxChangeset ?? 0,
106-
}),
124+
}))).ConfigureAwait(false))
125+
.SelectMany(changesets => changesets)
126+
.DistinctBy(changeset => changeset.ChangesetId)
127+
.OrderBy(changeset => changeset.ChangesetId)
128+
.ToList();
129+
130+
Console.WriteLine("Downloading labels...");
131+
132+
var allLabels = (await Task.WhenAll(directories.Select(directory =>
107133
client.GetLabelsAsync(
108-
new TfvcLabelRequestData
109-
{
110-
MaxItemCount = int.MaxValue,
111-
LabelScope = rootPath,
112-
},
113-
top: int.MaxValue)
114-
).ConfigureAwait(false);
134+
new TfvcLabelRequestData
135+
{
136+
MaxItemCount = int.MaxValue,
137+
LabelScope = rootPath + directory,
138+
},
139+
top: int.MaxValue))).ConfigureAwait(false))
140+
.SelectMany(labels => labels)
141+
.DistinctBy(label => label.Id)
142+
.ToList();
115143

116144
var authorsLookup = await LoadAuthors(
117145
authors,
@@ -135,14 +163,15 @@ pat is not null
135163
var commitsByChangeset = new Dictionary<int, List<(Commit Commit, BranchIdentity Branch, bool WasCreatedForChangeset)>>();
136164

137165
await using var mappingStateAndItemsEnumerator =
138-
EnumerateMappingStatesAsync(client, rootPathChanges, changesets, initialBranch)
166+
EnumerateMappingStatesAsync(client, directories, rootPathChanges, changesets, initialBranch)
139167
.SelectAwait(async state => (
140168
MappingState: state,
141169
// Make no attempt to reason about applying TFS item changes over time. Ask for the full set of files.
142170
Items: await DownloadItemsAsync(
143171
client,
144-
PathUtils.GetNonOverlappingPaths(
145-
state.BranchMappingsInDependentOperationOrder.Select(branchMapping => branchMapping.Mapping.RootDirectory)),
172+
from branchMapping in state.BranchMappingsInDependentOperationOrder
173+
from directory in directories
174+
select branchMapping.Mapping.RootDirectory + directory,
146175
state.Changeset)))
147176
.WithLookahead()
148177
.GetAsyncEnumerator();
@@ -438,6 +467,7 @@ private static void ReportProgress(int changeset, int total, TimedProgress timed
438467

439468
private static async IAsyncEnumerable<MappingState> EnumerateMappingStatesAsync(
440469
TfvcHttpClient client,
470+
ImmutableArray<string> directories,
441471
ImmutableArray<RootPathChange> rootPathChanges,
442472
IReadOnlyList<TfvcChangesetRef> changesets,
443473
BranchIdentity initialBranch)
@@ -451,7 +481,7 @@ private static async IAsyncEnumerable<MappingState> EnumerateMappingStatesAsync(
451481

452482
await using var changesetChangesEnumerator = changesets
453483
.Skip(1)
454-
.SelectAwait(changeset => client.GetChangesetChangesAsync(changeset.ChangesetId, top: int.MaxValue - 1))
484+
.SelectAwait(changeset => GetChangesAsync(client, changeset.ChangesetId, trunk.Path, directories))
455485
.WithLookahead()
456486
.GetAsyncEnumerator();
457487

@@ -606,6 +636,36 @@ private static ImmutableDictionary<string, Identity> LoadAuthors(Stream authorsF
606636
return builder.ToImmutable();
607637
}
608638

639+
private static async Task<List<TfvcChange>> GetChangesAsync(TfvcHttpClient client, int changesetId, string rootPath, ImmutableArray<string> directories)
640+
{
641+
var changes = await client.GetChangesetChangesAsync(changesetId, top: int.MaxValue - 1);
642+
643+
changes.RemoveAll(change =>
644+
!ItemPathPassesFilter(change.Item.Path, rootPath, directories)
645+
&& !ItemPathPassesFilter(change.SourceServerItem, rootPath, directories));
646+
647+
return changes;
648+
}
649+
650+
private static bool ItemPathPassesFilter(string itemPath, string rootPath, ImmutableArray<string> directories)
651+
{
652+
if (!PathUtils.IsAbsolute(rootPath))
653+
throw new ArgumentException("Root path must be absolute.", nameof(rootPath));
654+
655+
if (PathUtils.IsOrContains(rootPath, itemPath))
656+
{
657+
var remainingPath = itemPath.AsSpan(rootPath.Length);
658+
659+
foreach (var directory in directories)
660+
{
661+
if (PathUtils.IsOrContains(directory, remainingPath))
662+
return true;
663+
}
664+
}
665+
666+
return false;
667+
}
668+
609669
private static async Task<ImmutableArray<TfvcItem>> DownloadItemsAsync(TfvcHttpClient client, IEnumerable<string> scopePaths, int changeset)
610670
{
611671
var union = PathUtils.GetNonOverlappingPaths(scopePaths);

0 commit comments

Comments
 (0)