Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add path filtering support to version.json #399

Merged
merged 26 commits into from Jan 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
59 changes: 59 additions & 0 deletions 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`<br>`/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`<br>`./quux.txt`<br>`./sub-dir/foo.txt`<br>`../subdir/inclusion.txt` | File will be included. Path is relative to the `version.json` file. |
| `sub-dir`<br>`../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`<br>`:^../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). |
5 changes: 5 additions & 0 deletions doc/versionJson.md
Expand Up @@ -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
Expand Down Expand Up @@ -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).
1 change: 1 addition & 0 deletions readme.md
Expand Up @@ -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

Expand Down
106 changes: 106 additions & 0 deletions 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<ArgumentNullException>(() => new FilterPath(null, ""));
Assert.Throws<ArgumentException>(() => new FilterPath("", ""));
Assert.Throws<FormatException>(() => new FilterPath(":?", ""));
Assert.Throws<FormatException>(() => new FilterPath("../foo.txt", ""));
Assert.Throws<FormatException>(() => new FilterPath(".././a/../../foo.txt", "foo"));
}
}