diff --git a/doc/pathFilters.md b/doc/pathFilters.md new file mode 100644 index 00000000..27075c48 --- /dev/null +++ b/doc/pathFilters.md @@ -0,0 +1,59 @@ +# Path filters + +## Problem + +Some repositories may contain more than project. This is sometimes referred to as a _mono repo_ (as opposed to having a repo for each project - _many repo_). Imagine a repository structured as: + +- / + - Foo/ + - version.json => `{"version": "1.0"}` + - Bar/ + - version.json => `{"version": "2.1"}` + - Quux/ + - version.json => `{"version": "4.3"}` + - README.md + +With GitVersioning's default configuration, a commit to a given project's subtree will result in the version height bumping for all projects in the repository. This is typically not desirable. Intuitively, a commit to `Bar` should only cause a version bump for `Bar`, and not `Foo` or `Quux`. + +## Solution + +Path filters provide a way to filter which subtrees in the repository affect version height. Imagine the `version.json` files had a `pathFilter` property: + +```json +{ + "version": "1.0", + "pathFilters": ["."] +} +``` + +With this single path filter of `"."`, the version height for this project would only bump when a commit was made within that subtree. Now imagine all projects in the original example have this value for `pathFilters`. Consider the following commits to the repository, and note their effect on the version height for each project: + +| Paths changed | Result | +| ---------------------------------------- | -------------------------------------------------------------------------- | +| `/README.md` | Commit does not affect any project. No versions change. | +| `/Bar/Program.cs`
`/Quux/Quux.csproj` | Commit affects both `Bar` and `Quux`. Their patch versions will bump by 1. | +| `/Bar/MyClass.cs` | Commit affects only `Bar`. `Bar`'s patch version will bump by 1. | + +When absent, the implied value for `pathFilters` is: + +```json +{ + "pathFilters": [":/"] +} +``` + +This results in the entire repository tree being considered for version height calculations. This is the default behavior for GitVersioning. + +## Path filter format + +Path filters take on a variety of formats, and can specify paths relative to the `version.json` or relative to the root of the repository. See the [Path filter format](#path-filter-format) section for more information. + +Multiple path filters may also be specified. The order is irrelevant. After a path matches any non-exclude path filter, it will be run through all exclude path filter. If it matches, the path is ignored. + +| Path filter | Description | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `file-here.txt`
`./quux.txt`
`./sub-dir/foo.txt`
`../subdir/inclusion.txt` | File will be included. Path is relative to the `version.json` file. | +| `sub-dir`
`../sub-dir` | Directory will be included. Path is relative to the `version.json` file. | +| `:/dir/file.txt` | File will be included. Path is absolute (i.e., relative to the root of the repository). | +| `:!bar.txt`
`:^../foo/baz.txt` | File will be excluded. Path is relative to the `version.json` file. `:!` and `:^` prefixes are synonymous. | +| `:!/root-file.txt` | File will be excluded. Path is absolute (i.e., relative to the root of the repository). | diff --git a/doc/versionJson.md b/doc/versionJson.md index 4721d5bc..c4725921 100644 --- a/doc/versionJson.md +++ b/doc/versionJson.md @@ -33,6 +33,9 @@ The content of the version.json file is a JSON serialized object with these prop "nugetPackageVersion": { "semVer": 1 // optional. Set to either 1 or 2 to control how the NuGet package version string is generated. Default is 1. }, + "pathFilters": [ + // optional list of paths to consider when calculating version height. + ] "publicReleaseRefSpec": [ "^refs/heads/master$", // we release out of master "^refs/tags/v\\d+\\.\\d+" // we also release tags starting with vN.N @@ -70,3 +73,5 @@ The `publicReleaseRefSpec` field causes builds out of certain branches or tags to automatically drop the `-gabc123` git commit ID suffix from the version, making it convenient to build releases out of these refs with a friendly version number that assumes linear versioning. + +[Learn more about pathFilters](pathFilters.md). diff --git a/readme.md b/readme.md index 778425d8..d1299871 100644 --- a/readme.md +++ b/readme.md @@ -58,6 +58,7 @@ Also some special [cloud build considerations](doc/cloudbuild.md). This package calculates the version based on a combination of the version.json file, the git 'height' of the version, and the git commit ID. +The height can optionally be incremented only for those [commits that change certain paths](doc/pathFilters.md). ### Version generation diff --git a/src/NerdBank.GitVersioning.Tests/FilterPathTests.cs b/src/NerdBank.GitVersioning.Tests/FilterPathTests.cs new file mode 100644 index 00000000..c2e3525e --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/FilterPathTests.cs @@ -0,0 +1,106 @@ +using System; +using Nerdbank.GitVersioning; +using Xunit; + +public class FilterPathTests +{ + [Theory] + [InlineData("./", "foo", "foo")] + [InlineData("../relative-dir", "foo", "relative-dir")] + [InlineData("../../some/dir/here", "foo/multi/wow", "foo/some/dir/here")] + [InlineData("relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData("./relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData("./dir\\hi/relativepath.txt", "foo", "foo/dir/hi/relativepath.txt")] + [InlineData(".\\relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":^relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":!relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":!/absolutepath.txt", "foo", "absolutepath.txt")] + [InlineData(":!\\absolutepath.txt", "foo", "absolutepath.txt")] + [InlineData("../bar/relativepath.txt", "foo", "bar/relativepath.txt")] + [InlineData("/", "foo", "")] + [InlineData("/absolute/file.txt", "foo", "absolute/file.txt")] + [InlineData(":/", "foo", "")] + [InlineData(":/absolutepath.txt", "foo", "absolutepath.txt")] + [InlineData(":/bar/absolutepath.txt", "foo", "bar/absolutepath.txt")] + [InlineData(":\\bar\\absolutepath.txt", "foo", "bar/absolutepath.txt")] + public void CanBeParsedToRepoRelativePath(string pathSpec, string relativeTo, string expected) + { + Assert.Equal(expected, new FilterPath(pathSpec, relativeTo).RepoRelativePath); + } + + [Theory] + [InlineData(":!.", "foo", "foo")] + [InlineData(":!.", "foo", "foo/")] + [InlineData(":!.", "foo", "foo/relativepath.txt")] + [InlineData(":!relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":^relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":^./relativepath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":^../bar", "foo", "bar")] + [InlineData(":^../bar", "foo", "bar/")] + [InlineData(":^../bar", "foo", "bar/somefile.txt")] + [InlineData(":^/absolute.txt", "foo", "absolute.txt")] + public void PathsCanBeExcluded(string pathSpec, string relativeTo, string repoRelativePath) + { + Assert.True(new FilterPath(pathSpec, relativeTo, true).Excludes(repoRelativePath)); + Assert.True(new FilterPath(pathSpec, relativeTo, false).Excludes(repoRelativePath)); + } + + [Theory] + [InlineData(":!.", "foo", "foo.txt")] + [InlineData(":^relativepath.txt", "foo", "foo2/relativepath.txt")] + [InlineData(":^/absolute.txt", "foo", "absolute.txt.bak")] + [InlineData(":^/absolute.txt", "foo", "absolute")] + + // Not exclude paths + [InlineData(":/absolute.txt", "foo", "absolute.txt")] + [InlineData("/absolute.txt", "foo", "absolute.txt")] + [InlineData("../root.txt", "foo", "root.txt")] + [InlineData("relativepath.txt", "foo", "foo/relativepath.txt")] + public void NonMatchingPathsAreNotExcluded(string pathSpec, string relativeTo, string repoRelativePath) + { + Assert.False(new FilterPath(pathSpec, relativeTo, true).Excludes(repoRelativePath)); + Assert.False(new FilterPath(pathSpec, relativeTo, false).Excludes(repoRelativePath)); + } + + [Theory] + [InlineData(":!.", "foo", "Foo")] + [InlineData(":!.", "foo", "Foo/")] + [InlineData(":!.", "foo", "Foo/relativepath.txt")] + [InlineData(":!RelativePath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":^relativepath.txt", "foo", "Foo/RelativePath.txt")] + [InlineData(":^./relativepath.txt", "Foo", "foo/RelativePath.txt")] + [InlineData(":^../bar", "foo", "Bar")] + [InlineData(":^../bar", "foo", "Bar/")] + [InlineData(":^../bar", "foo", "Bar/SomeFile.txt")] + [InlineData(":^/absOLUte.txt", "foo", "Absolute.TXT")] + public void PathsCanBeExcludedCaseInsensitive(string pathSpec, string relativeTo, string repoRelativePath) + { + Assert.True(new FilterPath(pathSpec, relativeTo, true).Excludes(repoRelativePath)); + } + + [Theory] + [InlineData(":!.", "foo", "Foo")] + [InlineData(":!.", "foo", "Foo/")] + [InlineData(":!.", "foo", "Foo/relativepath.txt")] + [InlineData(":!RelativePath.txt", "foo", "foo/relativepath.txt")] + [InlineData(":^relativepath.txt", "foo", "Foo/RelativePath.txt")] + [InlineData(":^./relativepath.txt", "Foo", "foo/RelativePath.txt")] + [InlineData(":^../bar", "foo", "Bar")] + [InlineData(":^../bar", "foo", "Bar/")] + [InlineData(":^../bar", "foo", "Bar/SomeFile.txt")] + [InlineData(":^/absOLUte.txt", "foo", "Absolute.TXT")] + public void NonMatchingPathsAreNotExcludedCaseSensitive(string pathSpec, string relativeTo, string repoRelativePath) + { + Assert.False(new FilterPath(pathSpec, relativeTo, false).Excludes(repoRelativePath)); + } + + [Fact] + public void InvalidPathspecsThrow() + { + Assert.Throws(() => new FilterPath(null, "")); + Assert.Throws(() => new FilterPath("", "")); + Assert.Throws(() => new FilterPath(":?", "")); + Assert.Throws(() => new FilterPath("../foo.txt", "")); + Assert.Throws(() => new FilterPath(".././a/../../foo.txt", "foo")); + } +} \ No newline at end of file diff --git a/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs b/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs index 231c6a38..e88f0f8c 100644 --- a/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs +++ b/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs @@ -121,6 +121,278 @@ public void GetVersionHeight_VersionJsonHasParsingErrorsInHistory() Assert.Equal(0, this.Repo.GetVersionHeight()); } + [Fact] + public void GetVersionHeight_IntroducingFiltersIncrementsHeight() + { + this.WriteVersionFile(relativeDirectory: "some-sub-dir"); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { "./" }; + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + } + + [Theory] + [InlineData("./")] + [InlineData("../some-sub-dir")] + [InlineData("/some-sub-dir")] + [InlineData(":/some-sub-dir")] + public void GetVersionHeight_IncludeFilter(string includeFilter) + { + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { includeFilter }; + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Expect commit outside of project tree to not affect version height + var otherFilePath = Path.Combine(this.RepoPath, "my-file.txt"); + File.WriteAllText(otherFilePath, "hello"); + Commands.Stage(this.Repo, otherFilePath); + this.Repo.Commit("Add other file outside of project root", this.Signer, this.Signer); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Expect commit inside project tree to affect version height + var containedFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "another-file.txt"); + File.WriteAllText(containedFilePath, "hello"); + Commands.Stage(this.Repo, containedFilePath); + this.Repo.Commit("Add file within project root", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + } + + [Fact] + public void GetVersionHeight_IncludeExcludeFilter() + { + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { "./", ":^/some-sub-dir/ignore.txt", ":^excluded-dir" }; + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Commit touching excluded path does not affect version height + var ignoredFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "ignore.txt"); + File.WriteAllText(ignoredFilePath, "hello"); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Add excluded file", this.Signer, this.Signer); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Commit touching both excluded and included path does affect height + var includedFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "another-file.txt"); + File.WriteAllText(includedFilePath, "hello"); + File.WriteAllText(ignoredFilePath, "changed"); + Commands.Stage(this.Repo, includedFilePath); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + + // Commit touching excluded directory does not affect version height + var fileInExcludedDirPath = Path.Combine(this.RepoPath, "some-sub-dir", "excluded-dir", "ignore.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); + File.WriteAllText(fileInExcludedDirPath, "hello"); + Commands.Stage(this.Repo, fileInExcludedDirPath); + this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + } + + [Fact] + public void GetVersionHeight_IncludeExcludeFilter_NoProjectDirectory() + { + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { "./", ":^/some-sub-dir/ignore.txt", ":^/excluded-dir" }; + this.WriteVersionFile(versionData); + Assert.Equal(1, this.Repo.GetVersionHeight()); + + // Commit touching excluded path does not affect version height + var ignoredFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "ignore.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(ignoredFilePath)); + File.WriteAllText(ignoredFilePath, "hello"); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Add excluded file", this.Signer, this.Signer); + Assert.Equal(1, this.Repo.GetVersionHeight()); + + // Commit touching both excluded and included path does affect height + var includedFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "another-file.txt"); + File.WriteAllText(includedFilePath, "hello"); + File.WriteAllText(ignoredFilePath, "changed"); + Commands.Stage(this.Repo, includedFilePath); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight()); + + // Commit touching excluded directory does not affect version height + var fileInExcludedDirPath = Path.Combine(this.RepoPath, "excluded-dir", "ignore.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); + File.WriteAllText(fileInExcludedDirPath, "hello"); + Commands.Stage(this.Repo, fileInExcludedDirPath); + this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight()); + } + + [Theory] + [InlineData(":^/excluded-dir")] + [InlineData(":^../excluded-dir")] + public void GetVersionHeight_AddingExcludeDoesNotLowerHeight(string excludePathFilter) + { + var versionData = VersionOptions.FromVersion(new Version("1.2")); + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Commit a file which will later be ignored + var ignoredFilePath = Path.Combine(this.RepoPath, "excluded-dir", "ignore.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(ignoredFilePath)); + File.WriteAllText(ignoredFilePath, "hello"); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Add file which will later be excluded", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + + versionData.PathFilters = new[] { excludePathFilter }; + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(3, this.Repo.GetVersionHeight("some-sub-dir")); + + // Committing a change to an ignored file does not increment the version height + File.WriteAllText(ignoredFilePath, "changed"); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Change now excluded file", this.Signer, this.Signer); + Assert.Equal(3, this.Repo.GetVersionHeight("some-sub-dir")); + } + + [Fact] + public void GetVersionHeight_IncludeRoot() + { + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { ":/" }; + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Expect commit outside of project tree to affect version height + var otherFilePath = Path.Combine(this.RepoPath, "my-file.txt"); + File.WriteAllText(otherFilePath, "hello"); + Commands.Stage(this.Repo, otherFilePath); + this.Repo.Commit("Add other file outside of project root", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + + // Expect commit inside project tree to affect version height + var containedFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "another-file.txt"); + File.WriteAllText(containedFilePath, "hello"); + Commands.Stage(this.Repo, containedFilePath); + this.Repo.Commit("Add file within project root", this.Signer, this.Signer); + Assert.Equal(3, this.Repo.GetVersionHeight("some-sub-dir")); + } + + [Fact] + public void GetVersionHeight_IncludeRootExcludeSome() + { + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { ":/", ":^/excluded-dir" }; + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Expect commit in an excluded directory to not affect version height + var ignoredFilePath = Path.Combine(this.RepoPath, "excluded-dir", "my-file.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(ignoredFilePath)); + File.WriteAllText(ignoredFilePath, "hello"); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Add other file to excluded directory", this.Signer, this.Signer); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Expect commit within another directory to affect version height + var otherFilePath = Path.Combine(this.RepoPath, "another-dir", "another-file.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(otherFilePath)); + File.WriteAllText(otherFilePath, "hello"); + Commands.Stage(this.Repo, otherFilePath); + this.Repo.Commit("Add file within project root", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + } + + [Fact] + public void GetVersionHeight_ProjectDirectoryIsMoved() + { + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { "./", ":^/some-sub-dir/ignore.txt", ":^excluded-dir" }; + this.WriteVersionFile(versionData, "some-sub-dir"); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Commit touching excluded path does not affect version height + var ignoredFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "ignore.txt"); + File.WriteAllText(ignoredFilePath, "hello"); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Add excluded file", this.Signer, this.Signer); + Assert.Equal(1, this.Repo.GetVersionHeight("some-sub-dir")); + + // Commit touching both excluded and included path does affect height + var includedFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "another-file.txt"); + File.WriteAllText(includedFilePath, "hello"); + File.WriteAllText(ignoredFilePath, "changed"); + Commands.Stage(this.Repo, includedFilePath); + Commands.Stage(this.Repo, ignoredFilePath); + this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + + // Commit touching excluded directory does not affect version height + var fileInExcludedDirPath = Path.Combine(this.RepoPath, "some-sub-dir", "excluded-dir", "ignore.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); + File.WriteAllText(fileInExcludedDirPath, "hello"); + Commands.Stage(this.Repo, fileInExcludedDirPath); + this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer); + Assert.Equal(2, this.Repo.GetVersionHeight("some-sub-dir")); + + // Rename the project directory + Directory.Move(Path.Combine(this.RepoPath, "some-sub-dir"), Path.Combine(this.RepoPath, "new-project-dir")); + Commands.Stage(this.Repo, "some-sub-dir"); + Commands.Stage(this.Repo, "new-project-dir"); + this.Repo.Commit("Move project directory", this.Signer, this.Signer); + + // Version is reset as project directory cannot be find in the ancestor commit + Assert.Equal(1, this.Repo.GetVersionHeight("new-project-dir")); + } + + [Fact] + public void GetCommitsFromVersion_WithPathFilters() + { + var commitsAt121 = new List(); + var commitsAt122 = new List(); + var commitsAt123 = new List(); + + var versionData = VersionOptions.FromVersion(new Version("1.2")); + versionData.PathFilters = new[] { "./", ":^/some-sub-dir/ignore.txt", ":^excluded-dir" }; + commitsAt121.Add(this.WriteVersionFile(versionData, "some-sub-dir")); + + // Commit touching excluded path does not affect version height + var ignoredFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "ignore.txt"); + File.WriteAllText(ignoredFilePath, "hello"); + Commands.Stage(this.Repo, ignoredFilePath); + commitsAt121.Add(this.Repo.Commit("Add excluded file", this.Signer, this.Signer)); + + // Commit touching both excluded and included path does affect height + var includedFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "another-file.txt"); + File.WriteAllText(includedFilePath, "hello"); + File.WriteAllText(ignoredFilePath, "changed"); + Commands.Stage(this.Repo, includedFilePath); + Commands.Stage(this.Repo, ignoredFilePath); + commitsAt122.Add(this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer)); + + // Commit touching excluded directory does not affect version height + var fileInExcludedDirPath = Path.Combine(this.RepoPath, "some-sub-dir", "excluded-dir", "ignore.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); + File.WriteAllText(fileInExcludedDirPath, "hello"); + Commands.Stage(this.Repo, fileInExcludedDirPath); + commitsAt122.Add(this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer)); + + // Commit touching project directory affects version height + File.WriteAllText(includedFilePath, "more changes"); + Commands.Stage(this.Repo, includedFilePath); + commitsAt123.Add(this.Repo.Commit("Changed included file", this.Signer, this.Signer)); + + Assert.Equal( + commitsAt121.OrderBy(c => c.Sha), + this.Repo.GetCommitsFromVersion(new Version(1, 2, 1), "some-sub-dir").OrderBy(c => c.Sha)); + Assert.Equal( + commitsAt122.OrderBy(c => c.Sha), + this.Repo.GetCommitsFromVersion(new Version(1, 2, 2), "some-sub-dir").OrderBy(c => c.Sha)); + Assert.Equal( + commitsAt123.OrderBy(c => c.Sha), + this.Repo.GetCommitsFromVersion(new Version(1, 2, 3), "some-sub-dir").OrderBy(c => c.Sha)); + } + [Theory] [InlineData("2.2-alpha", "2.2-rc", false)] [InlineData("2.2-rc", "2.2", false)] diff --git a/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj b/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj index 33823156..24c761b3 100644 --- a/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj +++ b/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs b/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs index 2173802c..e9d611e4 100644 --- a/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs +++ b/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs @@ -321,6 +321,19 @@ public void GetVersion_ReadReleaseSettings_BranchName() Assert.Equal("someValue{version}", versionOptions.Release.BranchName); } + [Fact] + public void GetVersion_ReadPathFilters() + { + var json = @"{ ""version"" : ""1.2"", ""pathFilters"" : [ "":/root.txt"", ""./hello"" ] }"; + var path = Path.Combine(this.RepoPath, "version.json"); + File.WriteAllText(path, json); + + var versionOptions = VersionFile.GetVersion(this.RepoPath); + + Assert.NotNull(versionOptions.PathFilters); + Assert.Equal(new[] {":/root.txt", "./hello"}, versionOptions.PathFilters); + } + [Fact] public void GetVersion_String_MissingFile() { diff --git a/src/NerdBank.GitVersioning/FilterPath.cs b/src/NerdBank.GitVersioning/FilterPath.cs new file mode 100644 index 00000000..9e775040 --- /dev/null +++ b/src/NerdBank.GitVersioning/FilterPath.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using LibGit2Sharp; +using Validation; + +namespace Nerdbank.GitVersioning +{ + /// + /// A filter (include or exclude) representing a repo relative path. + /// + internal class FilterPath + { + private readonly StringComparison stringComparison; + + /// + /// True if this represents an exclude filter. + /// + internal bool IsExclude { get; } + + /// + /// Path relative to the repository root that this represents. + /// Slashes are canonical for this OS. + /// + internal string RepoRelativePath { get; } + + /// + /// True if this represents the root of the repository. + /// + internal bool IsRoot => this.RepoRelativePath == ""; + + /// + /// Parses a pathspec-like string into a root-relative path. + /// + /// + /// See for supported + /// formats of pathspecs. + /// + /// + /// Path that is relative to. + /// Can be null - which indicates is + /// relative to the root of the repository. + /// + /// + /// Forward slash delimited string representing the root-relative path. + /// + private static string ParsePath(string path, string relativeTo) + { + // Path is absolute, nothing to do here + if (path[0] == '/' || path[0] == '\\') + { + return path.Substring(1); + } + + var combined = relativeTo == null ? path : relativeTo + '/' + path; + + return string.Join("/", + combined + .Split(new[] {Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar}, + StringSplitOptions.RemoveEmptyEntries) + // Loop through each path segment... + .Aggregate(new Stack(), (parts, segment) => + { + switch (segment) + { + // If it refers to the current directory, skip it + case ".": + return parts; + + // If it refers to the parent directory, pop the most recent directory + case "..": + if (parts.Count == 0) + throw new FormatException($"Too many '..' in path '{combined}' - would escape the root of the repository."); + + parts.Pop(); + return parts; + + // Otherwise it's a directory/file name - add it to the stack + default: + parts.Push(segment); + return parts; + } + }) + // Reverse the stack, so it iterates root -> leaf + .Reverse() + ); + } + + /// + /// Construct a from a pathspec-like string and a + /// relative path within the repository. + /// + /// + /// A string that supports some pathspec features. + /// This path is relative to . + /// + /// Examples: + /// - ../relative/inclusion.txt + /// - :/absolute/inclusion.txt + /// - :!relative/exclusion.txt + /// - :^relative/exclusion.txt + /// - :^/absolute/exclusion.txt + /// + /// + /// Path (relative to the root of the repository) that is relative to. + /// + /// Whether case should be ignored by + /// Invalid path spec. + internal FilterPath(string pathSpec, string relativeTo, bool ignoreCase = false) + { + Requires.NotNullOrEmpty(pathSpec, nameof(pathSpec)); + + if (pathSpec[0] == ':') + { + if (pathSpec.Length > 1 && (pathSpec[1] == '^' || pathSpec[1] == '!')) + { + this.IsExclude = true; + this.stringComparison = ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + this.RepoRelativePath = ParsePath(pathSpec.Substring(2), relativeTo); + } + else if (pathSpec.Length > 1 && pathSpec[1] == '/' || pathSpec[1] == '\\') + { + this.RepoRelativePath = pathSpec.Substring(2); + } + else + { + throw new FormatException($"Unrecognized path spec '{pathSpec}'"); + } + } + else + { + this.RepoRelativePath = ParsePath(pathSpec, relativeTo); + } + + this.RepoRelativePath = + this.RepoRelativePath + .Replace('\\', '/') + .TrimEnd('/'); + } + + /// + /// Calculate the s for a given project within a repository. + /// + /// Version options for the project. + /// + /// Path to the project directory, relative to the root of the repository. + /// If null, assumes root of repository. + /// + /// Git repository containing the project. + /// + /// null if no path filters are set. Otherwise, returns a list of + /// instances. + /// + internal static IReadOnlyList FromVersionOptions(VersionOptions versionOptions, + string relativeRepoProjectDirectory, + IRepository repository) + { + Requires.NotNull(versionOptions, nameof(versionOptions)); + + var ignoreCase = repository?.Config.Get("core.ignorecase")?.Value ?? false; + + return versionOptions.PathFilters + ?.Select(pathSpec => new FilterPath(pathSpec, relativeRepoProjectDirectory, + ignoreCase)) + .ToList(); + } + + /// + /// Determines if should be excluded by this . + /// + /// Forward-slash delimited path (repo relative). + /// + /// True if this is an excluding filter that matches + /// , otherwise false. + /// + internal bool Excludes(string repoRelativePath) + { + if (repoRelativePath is null) + throw new ArgumentNullException(nameof(repoRelativePath)); + + if (!this.IsExclude) return false; + + return this.RepoRelativePath.Equals(repoRelativePath, this.stringComparison) || + repoRelativePath.StartsWith(this.RepoRelativePath + "/", + this.stringComparison); + } + } +} \ No newline at end of file diff --git a/src/NerdBank.GitVersioning/GitExtensions.cs b/src/NerdBank.GitVersioning/GitExtensions.cs index d7550e52..b31a4053 100644 --- a/src/NerdBank.GitVersioning/GitExtensions.cs +++ b/src/NerdBank.GitVersioning/GitExtensions.cs @@ -45,7 +45,9 @@ public static int GetVersionHeight(this Commit commit, string repoRelativeProjec Requires.NotNull(commit, nameof(commit)); Requires.Argument(repoRelativeProjectDirectory == null || !Path.IsPathRooted(repoRelativeProjectDirectory), nameof(repoRelativeProjectDirectory), "Path should be relative to repo root."); - var versionOptions = VersionFile.GetVersion(commit, repoRelativeProjectDirectory); + var tracker = new GitWalkTracker(repoRelativeProjectDirectory); + + var versionOptions = tracker.GetVersion(commit); if (versionOptions == null) { return 0; @@ -58,7 +60,7 @@ public static int GetVersionHeight(this Commit commit, string repoRelativeProjec var versionHeightPosition = versionOptions.VersionHeightPosition; if (versionHeightPosition.HasValue) { - int height = commit.GetHeight(c => CommitMatchesVersion(c, baseSemVer, versionHeightPosition.Value, repoRelativeProjectDirectory)); + int height = commit.GetHeight(repoRelativeProjectDirectory, c => CommitMatchesVersion(c, baseSemVer, versionHeightPosition.Value, tracker)); return height; } @@ -123,11 +125,28 @@ public static int GetVersionHeight(this Branch branch, string repoRelativeProjec /// /// The height of the commit. Always a positive integer. public static int GetHeight(this Commit commit, Func continueStepping = null) + { + return commit.GetHeight(null, continueStepping); + } + + /// + /// Gets the number of commits in the longest single path between + /// the specified commit and the most distant ancestor (inclusive). + /// + /// The commit to measure the height of. + /// The path to the directory of the project whose version is being queried, relative to the repo root. + /// + /// A function that returns false when we reach a commit that + /// should not be included in the height calculation. + /// May be null to count the height to the original commit. + /// + /// The height of the commit. Always a positive integer. + public static int GetHeight(this Commit commit, string repoRelativeProjectDirectory, Func continueStepping = null) { Requires.NotNull(commit, nameof(commit)); - var heights = new Dictionary(); - return GetCommitHeight(commit, heights, continueStepping); + var tracker = new GitWalkTracker(repoRelativeProjectDirectory); + return GetCommitHeight(commit, tracker, continueStepping); } /// @@ -143,7 +162,24 @@ public static int GetHeight(this Commit commit, Func continueStepp /// The height of the branch. public static int GetHeight(this Branch branch, Func continueStepping = null) { - return GetHeight(branch.Tip ?? throw new InvalidOperationException("No commit exists."), continueStepping); + return branch.GetHeight(null, continueStepping); + } + + /// + /// Gets the number of commits in the longest single path between + /// the specified branch's head and the most distant ancestor (inclusive). + /// + /// The branch to measure the height of. + /// The path to the directory of the project whose version is being queried, relative to the repo root. + /// + /// A function that returns false when we reach a commit that + /// should not be included in the height calculation. + /// May be null to count the height to the original commit. + /// + /// The height of the branch. + public static int GetHeight(this Branch branch, string repoRelativeProjectDirectory, Func continueStepping = null) + { + return GetHeight(branch.Tip ?? throw new InvalidOperationException("No commit exists."), repoRelativeProjectDirectory, continueStepping); } /// @@ -184,6 +220,16 @@ public static Commit GetCommitFromTruncatedIdInteger(this Repository repo, int t return repo.Lookup(EncodeAsHex(rawId)); } + /// + /// Returns the repository that belongs to. + /// + /// Member of the repository. + /// Repository that belongs to. + private static IRepository GetRepository(this IBelongToARepository repositoryMember) + { + return repositoryMember.Repository; + } + /// /// Encodes a commit from history in a /// so that the original commit can be found later. @@ -289,11 +335,12 @@ public static IEnumerable GetCommitsFromVersion(this Repository repo, Ve Requires.NotNull(repo, nameof(repo)); Requires.NotNull(version, nameof(version)); + var tracker = new GitWalkTracker(repoRelativeProjectDirectory); var possibleCommits = from commit in GetCommitsReachableFromRefs(repo).Distinct() - let commitVersionOptions = VersionFile.GetVersion(commit, repoRelativeProjectDirectory) + let commitVersionOptions = tracker.GetVersion(commit) where commitVersionOptions != null where !IsCommitIdMismatch(version, commitVersionOptions, commit) - where !IsVersionHeightMismatch(version, commitVersionOptions, commit, repoRelativeProjectDirectory) + where !IsVersionHeightMismatch(version, commitVersionOptions, commit, tracker) select commit; return possibleCommits; @@ -387,7 +434,7 @@ public static string FindLibGit2NativeBinaries(string basePath) /// /// Specifies whether to use default settings for looking up global and system settings. /// - /// By default ( == false), the repository will be configured to only + /// By default ( == false), the repository will be configured to only /// use the repository-level configuration ignoring system or user-level configuration (set using git config --global. /// Thus only settings explicitly set for the repo will be available. /// @@ -401,7 +448,7 @@ public static string FindLibGit2NativeBinaries(string basePath) /// /// /// In this mode, using Repository.Configuration.Get{string}("user.name") will return the - /// value set in the user's global git configuration unless set on the repository level, + /// value set in the user's global git configuration unless set on the repository level, /// matching the behavior of the git command. /// /// @@ -434,14 +481,14 @@ public static Repository OpenGitRepo(string pathUnderGitRepo, bool useDefaultCon /// The commit to test. /// The version to test for in the commit /// The last component of the version to include in the comparison. - /// The repo-relative directory from which was originally calculated. + /// The caching tracker for storing or fetching version information per commit. /// true if the matches the major and minor components of . - internal static bool CommitMatchesVersion(this Commit commit, SemanticVersion expectedVersion, SemanticVersion.Position comparisonPrecision, string repoRelativeProjectDirectory) + private static bool CommitMatchesVersion(this Commit commit, SemanticVersion expectedVersion, SemanticVersion.Position comparisonPrecision, GitWalkTracker tracker) { Requires.NotNull(commit, nameof(commit)); Requires.NotNull(expectedVersion, nameof(expectedVersion)); - var commitVersionData = VersionFile.GetVersion(commit, repoRelativeProjectDirectory); + var commitVersionData = tracker.GetVersion(commit); var semVerFromFile = commitVersionData?.Version; if (semVerFromFile == null) { @@ -479,14 +526,14 @@ internal static bool CommitMatchesVersion(this Commit commit, SemanticVersion ex /// The commit to test. /// The version to test for in the commit /// The last component of the version to include in the comparison. - /// The repo-relative directory from which was originally calculated. + /// The caching tracker for storing or fetching version information per commit. /// true if the matches the major and minor components of . - internal static bool CommitMatchesVersion(this Commit commit, Version expectedVersion, SemanticVersion.Position comparisonPrecision, string repoRelativeProjectDirectory) + private static bool CommitMatchesVersion(this Commit commit, Version expectedVersion, SemanticVersion.Position comparisonPrecision, GitWalkTracker tracker) { Requires.NotNull(commit, nameof(commit)); Requires.NotNull(expectedVersion, nameof(expectedVersion)); - var commitVersionData = VersionFile.GetVersion(commit, repoRelativeProjectDirectory); + var commitVersionData = tracker.GetVersion(commit); var semVerFromFile = commitVersionData?.Version; if (semVerFromFile == null) { @@ -525,7 +572,7 @@ private static int ReadVersionPosition(Version version, SemanticVersion.Position } } - private static bool IsVersionHeightMismatch(Version version, VersionOptions versionOptions, Commit commit, string repoRelativeProjectDirectory) + private static bool IsVersionHeightMismatch(Version version, VersionOptions versionOptions, Commit commit, GitWalkTracker tracker) { Requires.NotNull(version, nameof(version)); Requires.NotNull(versionOptions, nameof(versionOptions)); @@ -538,7 +585,7 @@ private static bool IsVersionHeightMismatch(Version version, VersionOptions vers int expectedVersionHeight = ReadVersionPosition(version, position.Value); var actualVersionOffset = versionOptions.VersionHeightOffsetOrDefault; - var actualVersionHeight = commit.GetHeight(c => CommitMatchesVersion(c, version, position.Value - 1, repoRelativeProjectDirectory)); + var actualVersionHeight = GetCommitHeight(commit, tracker, c => CommitMatchesVersion(c, version, position.Value - 1, tracker)); return expectedVersionHeight != actualVersionHeight + actualVersionOffset; } @@ -651,32 +698,79 @@ private static string EncodeAsHex(byte[] buffer) /// the specified branch's head and the most distant ancestor (inclusive). /// /// The commit to measure the height of. - /// A cache of commits and their heights. + /// The caching tracker for storing or fetching version information per commit. /// /// A function that returns false when we reach a commit that /// should not be included in the height calculation. /// May be null to count the height to the original commit. /// /// The height of the branch. - private static int GetCommitHeight(Commit commit, Dictionary heights, Func continueStepping) + private static int GetCommitHeight(Commit commit, GitWalkTracker tracker, Func continueStepping) { Requires.NotNull(commit, nameof(commit)); - Requires.NotNull(heights, nameof(heights)); + Requires.NotNull(tracker, nameof(tracker)); - int height; - if (!heights.TryGetValue(commit.Id, out height)) + if (!tracker.TryGetVersionHeight(commit, out int height)) { - height = 0; if (continueStepping == null || continueStepping(commit)) { + var versionOptions = tracker.GetVersion(commit); + var pathFilters = versionOptions != null ? FilterPath.FromVersionOptions(versionOptions, tracker.RepoRelativeDirectory, commit.GetRepository()) : null; + + var includePaths = + pathFilters + ?.Where(filter => !filter.IsExclude) + .Select(filter => filter.RepoRelativePath) + .ToList(); + + var excludePaths = pathFilters?.Where(filter => filter.IsExclude).ToList(); + + bool ContainsRelevantChanges(IEnumerable changes) => + excludePaths.Count == 0 + ? changes.Any() + // If there is a single change that isn't excluded, + // then this commit is relevant. + : changes.Any(change => !excludePaths.Any(exclude => exclude.Excludes(change.Path))); + height = 1; + + if (includePaths != null) + { + // If there are no include paths, or any of the include + // paths refer to the root of the repository, then do not + // filter the diff at all. + var diffInclude = + includePaths.Count == 0 || pathFilters.Any(filter => filter.IsRoot) + ? null + : includePaths; + + // If the diff between this commit and any of its parents + // does not touch a path that we care about, don't bump the + // height. + var relevantCommit = + commit.Parents.Any() + ? commit.Parents.Any(parent => ContainsRelevantChanges(commit.GetRepository().Diff + .Compare(parent.Tree, commit.Tree, diffInclude))) + : ContainsRelevantChanges(commit.GetRepository().Diff + .Compare(null, commit.Tree, diffInclude)); + + if (!relevantCommit) + { + height = 0; + } + } + if (commit.Parents.Any()) { - height += commit.Parents.Max(p => GetCommitHeight(p, heights, continueStepping)); + height += commit.Parents.Max(p => GetCommitHeight(p, tracker, continueStepping)); } } + else + { + height = 0; + } - heights[commit.Id] = height; + tracker.RecordHeight(commit, height); } return height; @@ -810,5 +904,33 @@ private static bool IsVersionFileChangedInWorkingCopy(Repository repo, string re workingCopyVersion = null; return false; } + + private class GitWalkTracker + { + private readonly Dictionary commitVersionCache = new Dictionary(); + private readonly Dictionary heights = new Dictionary(); + + internal GitWalkTracker(string repoRelativeDirectory) + { + this.RepoRelativeDirectory = repoRelativeDirectory; + } + + internal string RepoRelativeDirectory { get; } + + internal bool TryGetVersionHeight(Commit commit, out int height) => this.heights.TryGetValue(commit.Id, out height); + + internal void RecordHeight(Commit commit, int height) => this.heights.Add(commit.Id, height); + + internal VersionOptions GetVersion(Commit commit) + { + if (!this.commitVersionCache.TryGetValue(commit.Id, out VersionOptions options)) + { + options = VersionFile.GetVersion(commit, this.RepoRelativeDirectory); + this.commitVersionCache.Add(commit.Id, options); + } + + return options; + } + } } } diff --git a/src/NerdBank.GitVersioning/VersionOptions.cs b/src/NerdBank.GitVersioning/VersionOptions.cs index 3eaabef5..a36b320b 100644 --- a/src/NerdBank.GitVersioning/VersionOptions.cs +++ b/src/NerdBank.GitVersioning/VersionOptions.cs @@ -205,6 +205,14 @@ public int VersionHeightOffsetOrDefault [JsonIgnore] public ReleaseOptions ReleaseOrDefault => this.Release ?? ReleaseOptions.DefaultInstance; + /// + /// Gets or sets a list of paths to use to filter commits when calculating version height. + /// If a given commit does not affect any paths in this filter, it is ignored for version height calculations. + /// Paths should be relative to the root of the repository. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public string[] PathFilters { get; set; } + /// /// Gets or sets a value indicating whether this options object should inherit from an ancestor any settings that are not explicitly set in this one. /// diff --git a/src/NerdBank.GitVersioning/version.schema.json b/src/NerdBank.GitVersioning/version.schema.json index 258d89fb..7f772945 100644 --- a/src/NerdBank.GitVersioning/version.schema.json +++ b/src/NerdBank.GitVersioning/version.schema.json @@ -175,6 +175,15 @@ } }, "additionalProperties": false + }, + "pathFilters": { + "type": "array", + "description": "An array of pathspec-like strings that are used to filter commits when calculating the version height. A commit will not increment the version height if its changed files are not included by these filters.\nPaths are relative to this file. Paths relative to the root of the repository can be specified with the `:/` prefix.\nExclusions can be specified with a `:^` prefix for relative paths, or a `:^/` prefix for paths relative to the root of the repository.\nAfter a path matches any non-exclude filter, it will be run through all exclude filters. If it matches, the path is ignored.", + "items": { + "type": "string", + "pattern": "^(:\\^|:!|:/|[^:])" + }, + "uniqueItems": true } } },