Repository: google/yamlfmt
Pull request #224 (2024-11-26 15:02:33)

Feature: unified include/exclude file
Implementation of #223. Review comments are very welcome, but let's keep the design discussion in #223.

Fixes: #223

octo: I also added a commit updating the docs to reflect these changes.

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b41a1f0..cbb1ce1 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -21,12 +21,12 @@ jobs:
   run-lint:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v5
         with:
-          go-version: 1.18
+          go-version: 1.23
           cache: true
 
       - name: Install tools
@@ -39,12 +39,12 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: [ '1.18', '1.19', '1.20', '1.21', '1.22' ]
+        go: [ '1.21', '1.22', '1.23' ]
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v5
         with:
           go-version: ${{ matrix.go }}
           cache: true
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index ff23af5..30f9d88 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -29,13 +29,13 @@ jobs:
     if: startsWith(github.ref, 'refs/tags/')
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
         with:
           fetch-depth: 0
       - name: Setup Golang
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v5
         with:
-          go-version: 1.18
+          go-version: 1.23
           check-latest: true
           cache: true
       - name: Install Cosign
diff --git a/Makefile b/Makefile
index 314a11e..2790ae3 100644
--- a/Makefile
+++ b/Makefile
@@ -43,8 +43,7 @@ command_test_case:
 ifndef TESTNAME
 	$(error "TESTNAME undefined")
 endif
-	mkdir -p integrationtest/command/testdata/$(TESTNAME)/before && \
-	mkdir -p integrationtest/command/testdata/$(TESTNAME)/stdout
+	./integrationtest/command/new_test_case.sh "$(TESTNAME)"
 
 .PHONY: install
 install:
diff --git a/README.md b/README.md
index 4c31bcb..497e82a 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ To download the `yamlfmt` command, you can download the desired binary from rele
 ```
 go install github.com/google/yamlfmt/cmd/yamlfmt@latest
 ```
-This currently requires Go version 1.18 or greater.
+This currently requires Go version 1.21 or greater.
 
 NOTE: Recommended setup if this is your first time installing Go would be in [this DigitalOcean blog post](https://www.digitalocean.com/community/tutorials/how-to-build-and-install-go-programs).
 
diff --git a/cmd/yamlfmt/config.go b/cmd/yamlfmt/config.go
index 27e4ddf..1174e84 100644
--- a/cmd/yamlfmt/config.go
+++ b/cmd/yamlfmt/config.go
@@ -27,6 +27,7 @@ import (
 	"github.com/braydonk/yaml"
 	"github.com/google/yamlfmt"
 	"github.com/google/yamlfmt/command"
+	"github.com/google/yamlfmt/engine"
 	"github.com/google/yamlfmt/internal/collections"
 	"github.com/google/yamlfmt/internal/logger"
 	"github.com/mitchellh/mapstructure"
@@ -278,12 +279,14 @@ func makeCommandConfigFromData(configData map[string]any) (*command.Config, erro
 	if !config.GitignoreExcludes {
 		config.GitignoreExcludes = *flagGitignoreExcludes
 	}
-	if config.GitignorePath == "" {
-		config.GitignorePath = *flagGitignorePath
-	}
-	if config.OutputFormat == "" {
-		config.OutputFormat = getOutputFormatFromFlag()
+	config.GitignorePath = pickFirst(config.GitignorePath, *flagGitignorePath)
+	config.OutputFormat = pickFirst(config.OutputFormat, getOutputFormatFromFlag(), engine.EngineOutputDefault)
+
+	defaultMatchType := yamlfmt.MatchTypeStandard
+	if config.Doublestar {
+		defaultMatchType = yamlfmt.MatchTypeDoublestar
 	}
+	config.MatchType = pickFirst(config.MatchType, yamlfmt.MatchType(*flagMatchType), defaultMatchType)
 
 	// Overwrite config if includes are provided through args
 	if len(flag.Args()) > 0 {
@@ -297,6 +300,17 @@ func makeCommandConfigFromData(configData map[string]any) (*command.Config, erro
 	return &config, nil
 }
 
+// pickFirst returns the first string in ss that is not empty.
+func pickFirst[T ~string](ss ...T) T {
+	for _, s := range ss {
+		if s != "" {
+			return s
+		}
+	}
+
+	return ""
+}
+
 func parseFormatterConfigFlag(flagValues []string) (map[string]any, error) {
 	formatterValues := map[string]any{}
 	flagErrors := collections.Errors{}
diff --git a/cmd/yamlfmt/flags.go b/cmd/yamlfmt/flags.go
index c6fdf96..7137723 100644
--- a/cmd/yamlfmt/flags.go
+++ b/cmd/yamlfmt/flags.go
@@ -41,6 +41,7 @@ operation without performing it.`)
 	flagGitignoreExcludes *bool   = flag.Bool("gitignore_excludes", false, "Use a gitignore file for excludes")
 	flagGitignorePath     *string = flag.String("gitignore_path", ".gitignore", "Path to gitignore file to use")
 	flagOutputFormat      *string = flag.String("output_format", "default", "The engine output format")
+	flagMatchType         *string = flag.String("match_type", "", "The file discovery method to use. Valid values: standard, doublestar, gitignore")
 	flagExclude                   = arrayFlag{}
 	flagFormatter                 = arrayFlag{}
 	flagExtensions                = arrayFlag{}
diff --git a/command/command.go b/command/command.go
index 3f75c40..3224f46 100644
--- a/command/command.go
+++ b/command/command.go
@@ -40,6 +40,7 @@ func NewFormatterConfig() *FormatterConfig {
 
 type Config struct {
 	Extensions        []string                  `mapstructure:"extensions"`
+	MatchType         yamlfmt.MatchType         `mapstructure:"match_type"`
 	Include           []string                  `mapstructure:"include"`
 	Exclude           []string                  `mapstructure:"exclude"`
 	RegexExclude      []string                  `mapstructure:"regex_exclude"`
@@ -190,7 +191,11 @@ func (c *Command) getFormatter() (yamlfmt.Formatter, error) {
 }
 
 func (c *Command) collectPaths() ([]string, error) {
-	collector := c.makePathCollector()
+	collector, err := c.makePathCollector()
+	if err != nil {
+		return nil, err
+	}
+
 	return collector.CollectPaths()
 }
 
@@ -203,17 +208,31 @@ func (c *Command) analyzePaths(paths []string) ([]string, error) {
 	return includePaths, err
 }
 
-func (c *Command) makePathCollector() yamlfmt.PathCollector {
-	if c.Config.Doublestar {
+func (c *Command) makePathCollector() (yamlfmt.PathCollector, error) {
+	switch c.Config.MatchType {
+	case yamlfmt.MatchTypeDoublestar:
 		return &yamlfmt.DoublestarCollector{
 			Include: c.Config.Include,
 			Exclude: c.Config.Exclude,
+		}, nil
+	case yamlfmt.MatchTypeGitignore:
+		files := c.Config.Include
+		if len(files) == 0 {
+			files = []string{yamlfmt.DefaultPatternFile}
 		}
-	}
-	return &yamlfmt.FilepathCollector{
-		Include:    c.Config.Include,
-		Exclude:    c.Config.Exclude,
-		Extensions: c.Config.Extensions,
+
+		patternFile, err := yamlfmt.NewPatternFileCollector(files...)
+		if err != nil {
+			return nil, fmt.Errorf("NewPatternFile(%q): %w", files, err)
+		}
+
+		return patternFile, nil
+	default:
+		return &yamlfmt.FilepathCollector{
+			Include:    c.Config.Include,
+			Exclude:    c.Config.Exclude,
+			Extensions: c.Config.Extensions,
+		}, nil
 	}
 }
 
diff --git a/docs/command-usage.md b/docs/command-usage.md
index 4d9d283..3ae3e96 100644
--- a/docs/command-usage.md
+++ b/docs/command-usage.md
@@ -81,6 +81,7 @@ The string array flags can be a bit confusing. See the [String Array Flags](#str
 | Global Config         | `-global_conf`        | bool              | `yamlfmt -global_conf`                                    | Force yamlfmt to use the configuration file from the system config directory. |
 | Disable Global Config | `-no_global_conf`     | bool              | `yamlfmt -no_global_conf`                                 | Disable looking for the configuration file from the system config directory. |
 | Doublestar            | `-dstar`              | bool              | `yamlfmt -dstar "**/*.yaml"`                              | Enable [Doublestar](./paths.md#doublestar) path collection mode. Note that doublestar patterns should be specified with quotes in bash to prevent shell expansion. |
+| Match type            | `-match_type`         | string            | `yamlfmt -match_type standard`                            | Controls how `include` and `exclude` are interpreted. See [Specifying Paths](./paths.md) for more details. |
 | Exclude               | `-exclude`            | []string          | `yamlfmt -exclude ./not/,these_paths.yaml`                | Patterns to exclude from path collection. These are in addition to the exclude patterns specified in the [config file](./config-file.md) |
 | Gitignore Excludes    | `-gitignore_excludes` | bool              | `yamlfmt -gitignore_excludes`                             | Use a gitignore file to exclude paths. This is in addition to otherwise specified exclude patterns. |
 | Gitignore Path        | `-gitignore_path`     | string            | `yamlfmt -gitignore_path .special_gitignore`              | Specify a path to a gitignore file to use. Defaults to `.gitignore` (in working directory). |
diff --git a/docs/config-file.md b/docs/config-file.md
index 16404c2..a0f9efb 100644
--- a/docs/config-file.md
+++ b/docs/config-file.md
@@ -39,6 +39,7 @@ The command package defines the main command engine that `cmd/yamlfmt` uses. It
 | `line_ending`            | `lf` or `crlf` | `crlf` on Windows, `lf` otherwise | Parse and write the file with "lf" or "crlf" line endings. This global setting will override any formatter `line_ending` options. |
 | `doublestar`             | bool                | false         | Use [doublestar](https://github.com/bmatcuk/doublestar) for include and exclude paths. (This was the default before 0.7.0) |
 | `continue_on_error`      | bool                | false         | Continue formatting and don't exit with code 1 when there is an invalid yaml file found. |
+| `match_type`             | string              | `standard`    | Controls how `include` and `exclude` are interpreted. See [Specifying Paths][] for more details. |
 | `include`                | []string            | []            | The paths for the command to include for formatting. See [Specifying Paths][] for more details. |
 | `exclude`                | []string            | []            | The paths for the command to exclude from formatting. See [Specifying Paths][] for more details. |
 | `gitignore_excludes`     | bool                | false         | Use gitignore files for exclude paths. This is in addition to the patterns from the `exclude` option. |
diff --git a/docs/paths.md b/docs/paths.md
index a98236c..f6c971c 100644
--- a/docs/paths.md
+++ b/docs/paths.md
@@ -1,6 +1,6 @@
 # Paths
 
-`yamlfmt` can collect paths in two modes: Standard, or Doublestar.
+`yamlfmt` can collect paths in three modes: Standard, Doublestar, and Gitignore. The `match_type` option allows you to select between the modes, using the values `standard`, `doublestar`, and `gitignore`.
 
 ## Standard (default)
 
@@ -12,14 +12,41 @@ This mode does *not* support wildcards, aka. globbing. That means with `*.yaml`
 
 In Doublestar mode, paths are specified using wildcard patterns explained in the [doublestar](https://github.com/bmatcuk/doublestar) package. It is almost identical to bash and git's style of glob pattern specification.
 
-To enable the doublestar mode, set `doublestar: true` in the config file or use the `-dstar` command line flag.
+To enable the doublestar mode, set `match_type: doublestar` in the config file or use the `-match_type doublestar` command line flag.
+
+## Gitignore
+
+This mode allows you to use a file (or files) using the [gitignore](https://git-scm.com/docs/gitignore) syntax to determine which files to include and exclude.
+
+Despite having "ignore" in the name, yamlfmt will format all files that match the patterns listed in the file, unless the patterns are negated. For example, the following file will format all `*.yaml` and `*.yml` files, while ignoring other files such as `README.md` as well as files in `testdata` directories:
+
+```gitignore
+# Include
+*.yaml
+*.yml
+
+# Exclude
+!testdata/
+```
+
+Please read the [gitignore manpage](https://git-scm.com/docs/gitignore) for the full syntax description and further examples.
+
+To use the `gitignore` mode, set `match_type: gitignore` in the config file or use the `-match_type gitignore` command line flag.
+In this mode, positional arguments on the command line and files listed in the `include` config option are considered to be files using the gitignore syntax.
+If no file is specified, yamlfmt will look for a file called `yamlfmt.patterns` in the working directory.
+
+The `exclude` option is ignored in this mode.
+
 
 ## Include and Exclude
 
-In both modes, `yamlfmt` will allow you to configure include and exclude paths. These can be paths to files in Standard or Doublestar modes, paths to directories in Standard mode, and valid doublestar patterns in Doublestar mode. These paths should be specified **relative to the working directory of `yamlfmt`**. They will work as absolute paths if both the includes and excludes are specified as absolute paths or if both are relative paths, however it will not work as expected if they are mixed together. It usually easier to reason about includes and excludes when always specifying both as relative paths from the directory `yamlfmt` is going to be run in.
+In the `standard` and `doublestar` modes, `yamlfmt` will allow you to configure include and exclude paths. These can be paths to files in Standard or Doublestar modes, paths to directories in Standard mode, and valid doublestar patterns in Doublestar mode. These paths should be specified **relative to the working directory of `yamlfmt`**. They will work as absolute paths if both the includes and excludes are specified as absolute paths or if both are relative paths, however it will not work as expected if they are mixed together. It usually easier to reason about includes and excludes when always specifying both as relative paths from the directory `yamlfmt` is going to be run in.
+
+In the `gitignore` mode, files specified on the command line or via the `include` config option are expected to contain patterns following the [gitignore](https://git-scm.com/docs/gitignore) syntax.
 
 Exclude paths can be specified on the command line using the `-exclude` flag.
 Paths excluded from the command line are **added* to excluded paths from the config file.
+The `gitignore` mode ignores the `exclude` option.
 
 Include paths can be specified on the command line via the positional arguments, i.e. there is no flag for it.
 Paths from the command line take precedence over and **replace** any paths configured in the config file.
diff --git a/go.mod b/go.mod
index f7df79f..b710e29 100644
--- a/go.mod
+++ b/go.mod
@@ -1,13 +1,14 @@
 module github.com/google/yamlfmt
 
-go 1.18
+go 1.21
 
 require (
-	github.com/bmatcuk/doublestar/v4 v4.6.0
+	github.com/bmatcuk/doublestar/v4 v4.7.1
 	github.com/braydonk/yaml v0.7.0
-	github.com/google/go-cmp v0.5.9
+	github.com/google/go-cmp v0.6.0
 	github.com/mitchellh/mapstructure v1.5.0
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
+	github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
 )
 
-require github.com/santhosh-tekuri/jsonschema/v4 v4.0.0
+require golang.org/x/text v0.14.0 // indirect
diff --git a/go.sum b/go.sum
index 3a691f0..95ce3e6 100644
--- a/go.sum
+++ b/go.sum
@@ -1,22 +1,26 @@
-github.com/bmatcuk/doublestar/v4 v4.6.0 h1:HTuxyug8GyFbRkrffIpzNCSK4luc0TY3wzXvzIZhEXc=
-github.com/bmatcuk/doublestar/v4 v4.6.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
+github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/braydonk/yaml v0.7.0 h1:ySkqO7r0MGoCNhiRJqE0Xe9yhINMyvOAB3nFjgyJn2k=
 github.com/braydonk/yaml v0.7.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
-github.com/santhosh-tekuri/jsonschema/v4 v4.0.0 h1:5yvbsmEfOQE2+YuRudUhXPzMQK6P74AR3LafM5zrzf0=
-github.com/santhosh-tekuri/jsonschema/v4 v4.0.0/go.mod h1:P2UNYJ2xnL4zRyQ2obmD35HiejSJhHTKwMcckhPucG4=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
diff --git a/internal/tempfile/golden.go b/internal/tempfile/golden.go
index b14b46b..624fa07 100644
--- a/internal/tempfile/golden.go
+++ b/internal/tempfile/golden.go
@@ -43,7 +43,7 @@ func (g GoldenCtx) CompareGoldenFile(path string, gotContent []byte) error {
 	// If we are not updating, check that the content is the same.
 	expectedContent, err := os.ReadFile(g.goldenPath(path))
 	if err != nil {
-		return err
+		return fmt.Errorf("os.ReadFile(%q): %w", g.goldenPath(path), err)
 	}
 	// Edge case for empty stdout.
 	if gotContent == nil {
@@ -84,11 +84,11 @@ func (g GoldenCtx) CompareDirectory(resultPath string) error {
 	for path := range resultPaths {
 		gotContent, err := os.ReadFile(filepath.Join(resultPath, path))
 		if err != nil {
-			return fmt.Errorf("%s: %w", path, err)
+			return fmt.Errorf("os.ReadFile(%q): %w", path, err)
 		}
 		err = g.CompareGoldenFile(path, gotContent)
 		if err != nil {
-			return fmt.Errorf("%s: %w", path, err)
+			return fmt.Errorf("CompareGoldenFile(%q): %w", path, err)
 		}
 	}
 	// If there are no errors this will be nil, otherwise will be a
@@ -118,7 +118,7 @@ func (g GoldenCtx) updateGoldenDirectory(resultPath string) error {
 func readAllPaths(dirPath string) (collections.Set[string], error) {
 	paths := collections.Set[string]{}
 	allNamesButCurrentDirectory := func(path string, d fs.DirEntry, err error) error {
-		if path == dirPath {
+		if d.IsDir() {
 			return nil
 		}
 		paths.Add(d.Name())
diff --git a/path_collector.go b/path_collector.go
index 2b8d516..7967f3c 100644
--- a/path_collector.go
+++ b/path_collector.go
@@ -15,8 +15,11 @@
 package yamlfmt
 
 import (
+	"bufio"
+	"bytes"
 	"errors"
 	"fmt"
+	"io"
 	"io/fs"
 	"os"
 	"path/filepath"
@@ -28,6 +31,14 @@ import (
 	ignore "github.com/sabhiram/go-gitignore"
 )
 
+type MatchType string
+
+const (
+	MatchTypeStandard   MatchType = "standard"
+	MatchTypeDoublestar MatchType = "doublestar"
+	MatchTypeGitignore  MatchType = "gitignore"
+)
+
 type PathCollector interface {
 	CollectPaths() ([]string, error)
 }
@@ -230,3 +241,96 @@ func ExcludeWithGitignore(gitignorePath string, paths []string) ([]string, error
 	logger.Debug(logger.DebugCodePaths, "paths to format: %s", pathsToFormat)
 	return pathsToFormat, nil
 }
+
+const DefaultPatternFile = "yamlfmt.patterns"
+
+// PatternFileCollector determines which files to format and which to ignore based on a pattern file in gitignore(5) syntax.
+type PatternFileCollector struct {
+	fs      fs.FS
+	matcher *ignore.GitIgnore
+}
+
+// NewPatternFileCollector initializes a new PatternFile using the provided file(s).
+// If multiple files are provided, their content is concatenated in order.
+// All patterns are relative to the current working directory.
+func NewPatternFileCollector(files ...string) (*PatternFileCollector, error) {
+	r, err := cat(files...)
+	if err != nil {
+		return nil, err
+	}
+
+	wd, err := os.Getwd()
+	if err != nil {
+		return nil, fmt.Errorf("os.Getwd: %w", err)
+	}
+
+	return NewPatternFileCollectorFS(r, os.DirFS(wd)), nil
+}
+
+// cat concatenates the contents of all files in its argument list.
+func cat(files ...string) (io.Reader, error) {
+	var b bytes.Buffer
+
+	for _, f := range files {
+		fh, err := os.Open(f)
+		if err != nil {
+			return nil, err
+		}
+		defer fh.Close()
+
+		if _, err := io.Copy(&b, fh); err != nil {
+			return nil, fmt.Errorf("copying %q: %w", f, err)
+		}
+		fh.Close()
+
+		// Append a newline to avoid issues with files lacking a newline at end-of-file.
+		fmt.Fprintln(&b)
+	}
+
+	return &b, nil
+}
+
+// NewPatternFileCollectorFS reads a pattern file from r and uses fs for file lookups.
+// It is used by NewPatternFile and primarily public because it is useful for testing.
+func NewPatternFileCollectorFS(r io.Reader, fs fs.FS) *PatternFileCollector {
+	var lines []string
+
+	s := bufio.NewScanner(r)
+	for s.Scan() {
+		lines = append(lines, s.Text())
+	}
+
+	return &PatternFileCollector{
+		fs:      fs,
+		matcher: ignore.CompileIgnoreLines(lines...),
+	}
+}
+
+// CollectPaths implements the PathCollector interface.
+func (c *PatternFileCollector) CollectPaths() ([]string, error) {
+	var files []string
+
+	err := fs.WalkDir(c.fs, ".", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+
+		ok, pattern := c.matcher.MatchesPathHow(path)
+		switch {
+		case ok && pattern.Negate && d.IsDir():
+			return fs.SkipDir
+		case ok && pattern.Negate:
+			return nil
+		case ok && d.Type().IsRegular():
+			files = append(files, path)
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return nil, fmt.Errorf("WalkDir: %w", err)
+	}
+
+	return files, nil
+}


Test patch:
diff --git a/docs/integration-test.md b/docs/integration-test.md
new file mode 100644
index 0000000..ba845ce
--- /dev/null
+++ b/docs/integration-test.md
@@ -0,0 +1,36 @@
+# Integration Testing
+
+yamlfmt has integration testing that tests invocations end-to-end.
+
+## Run
+
+Integration tests are excluded from `go test` by default using build tags. To build and execute the integration test, use:
+
+```shell
+make integrationtest
+```
+
+## Update
+
+The "golden" / expected output files can be (re-)generated by the test framework. To do so, run:
+
+```shell
+make integrationtest_update
+```
+
+## Add new test case
+
+* Run the following command to create the directory structure for the new test:
+
+  ```shell
+  TESTNAME=my_new_test make command_test_case
+  ```
+* Add example input files to `testdata/${TESTNAME}/before`.
+* Add the expected output to `testdata/${TESTNAME}/stdout/stdout.txt` if applicable.
+* `make command_test_case` added a new Go test to `integrationtest/command/command_test.go`. Update the `yamlfmt` flags so the invocation uses the feature the test should cover.
+* Run the following command to generate the expected "golden" output of the test:
+
+  ```shell
+  make integrationtest_update
+  ```
+* Review the generated output to ensure it is expected.
diff --git a/integrationtest/command/command_test.go b/integrationtest/command/command_test.go
index 46b162f..1fff369 100644
--- a/integrationtest/command/command_test.go
+++ b/integrationtest/command/command_test.go
@@ -155,3 +155,11 @@ func TestGitLabOutput(t *testing.T) {
 		Update:  *updateFlag,
 	}.Run(t)
 }
+
+func TestPatternFile(t *testing.T) {
+	TestCase{
+		Dir:     "pattern_file",
+		Command: yamlfmtWithArgs("-match_type gitignore yamlfmt.patterns"),
+		Update:  *updateFlag,
+	}.Run(t)
+}
diff --git a/integrationtest/command/new_test_case.sh b/integrationtest/command/new_test_case.sh
new file mode 100755
index 0000000..cf97add
--- /dev/null
+++ b/integrationtest/command/new_test_case.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+
+# Copyright 2024 GitLab, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu -o pipefail
+
+if [[ $# -ne 1 ]]; then
+	echo "Usage: $0 <testname>" >&2
+	exit 1
+fi
+
+TESTNAME="$1"
+shift
+
+declare -r BEFORE_DIR="integrationtest/command/testdata/${TESTNAME:?}/before"
+declare -r AFTER_DIR="integrationtest/command/testdata/${TESTNAME:?}/after"
+declare -r OUTPUT_DIR="integrationtest/command/testdata/${TESTNAME:?}/stdout"
+declare -r GO_TEST_FILE="integrationtest/command/command_test.go"
+
+if [[ ! -d "${BEFORE_DIR}" ]]; then
+	mkdir -p "${BEFORE_DIR}"
+	cat >"${BEFORE_DIR}/x.yaml" <<EOF
+TODO: add test input
+EOF
+fi
+
+if [[ ! -d "${AFTER_DIR}" ]]; then
+	mkdir -p "${AFTER_DIR}"
+	cat >"${AFTER_DIR}/x.yaml" <<EOF
+TODO: add test input
+EOF
+fi
+
+mkdir -p "${OUTPUT_DIR}"
+touch "${OUTPUT_DIR}/stdout.txt"
+touch "${OUTPUT_DIR}/stderr.txt"
+
+# Converty "my_test_case" to "MyTestCase".
+if sed --version 2>/dev/null | grep -q GNU; then
+	# \U (convert to upper case) is a GNU extension.
+	GO_TESTNAME="$(sed -r -e 's/(^|_)(\w)/\U\2/g' <<<"${TESTNAME}")"
+else
+	# Fall back to Perl.
+	GO_TESTNAME="$(perl -pe 's/(^|_)(\w)/\U\2/g' <<<"${TESTNAME}")"
+fi
+
+if ! grep -q -F "Test${GO_TESTNAME:?}" "${GO_TEST_FILE}"; then
+	cat >>"${GO_TEST_FILE}" <<EOF
+
+func Test${GO_TESTNAME:?}(t *testing.T) {
+	TestCase{
+		Dir:     "${TESTNAME:?}",
+		// TODO: Change arguments to match your test case.
+		Command: yamlfmtWithArgs("."),
+		Update:  *updateFlag,
+	}.Run(t)
+}
+EOF
+	gofmt -w "${GO_TEST_FILE}"
+fi
+
+cat <<EOF
+Next steps:
+
+* Write test input files to '${BEFORE_DIR}'
+* Write expected output to '${OUTPUT_DIR}/stdout.txt'
+* Optional: write expected error output to '${OUTPUT_DIR}/stderr.txt'
+* Add yamlfmt arguments to the 'Test${GO_TESTNAME}' test in '${GO_TEST_FILE}'
+* Run 'make integrationtest_update' and review the files in '${AFTER_DIR}'
+EOF
diff --git a/integrationtest/command/testdata/pattern_file/after/a.yaml b/integrationtest/command/testdata/pattern_file/after/a.yaml
new file mode 100755
index 0000000..28e1bff
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/after/a.yaml
@@ -0,0 +1,1 @@
+formatted: true
diff --git a/integrationtest/command/testdata/pattern_file/after/b.yml b/integrationtest/command/testdata/pattern_file/after/b.yml
new file mode 100755
index 0000000..28e1bff
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/after/b.yml
@@ -0,0 +1,1 @@
+formatted: true
diff --git a/integrationtest/command/testdata/pattern_file/after/excluded_dir/a.yaml b/integrationtest/command/testdata/pattern_file/after/excluded_dir/a.yaml
new file mode 100644
index 0000000..0eea297
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/after/excluded_dir/a.yaml
@@ -0,0 +1,1 @@
+formatted:  true
diff --git a/integrationtest/command/testdata/pattern_file/after/excluded_file.yaml b/integrationtest/command/testdata/pattern_file/after/excluded_file.yaml
new file mode 100755
index 0000000..0eea297
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/after/excluded_file.yaml
@@ -0,0 +1,1 @@
+formatted:  true
diff --git a/integrationtest/command/testdata/pattern_file/after/included_dir/a.yaml b/integrationtest/command/testdata/pattern_file/after/included_dir/a.yaml
new file mode 100755
index 0000000..28e1bff
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/after/included_dir/a.yaml
@@ -0,0 +1,1 @@
+formatted: true
diff --git a/integrationtest/command/testdata/pattern_file/after/yamlfmt.patterns b/integrationtest/command/testdata/pattern_file/after/yamlfmt.patterns
new file mode 100755
index 0000000..915fd24
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/after/yamlfmt.patterns
@@ -0,0 +1,7 @@
+# vim: syntax=gitignore
+
+*.yaml
+*.yml
+
+!excluded_file.yaml
+!excluded_dir/
diff --git a/integrationtest/command/testdata/pattern_file/before/a.yaml b/integrationtest/command/testdata/pattern_file/before/a.yaml
new file mode 100644
index 0000000..0eea297
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/before/a.yaml
@@ -0,0 +1,1 @@
+formatted:  true
diff --git a/integrationtest/command/testdata/pattern_file/before/b.yml b/integrationtest/command/testdata/pattern_file/before/b.yml
new file mode 100644
index 0000000..0eea297
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/before/b.yml
@@ -0,0 +1,1 @@
+formatted:  true
diff --git a/integrationtest/command/testdata/pattern_file/before/excluded_dir/a.yaml b/integrationtest/command/testdata/pattern_file/before/excluded_dir/a.yaml
new file mode 100644
index 0000000..0eea297
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/before/excluded_dir/a.yaml
@@ -0,0 +1,1 @@
+formatted:  true
diff --git a/integrationtest/command/testdata/pattern_file/before/excluded_file.yaml b/integrationtest/command/testdata/pattern_file/before/excluded_file.yaml
new file mode 100644
index 0000000..0eea297
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/before/excluded_file.yaml
@@ -0,0 +1,1 @@
+formatted:  true
diff --git a/integrationtest/command/testdata/pattern_file/before/included_dir/a.yaml b/integrationtest/command/testdata/pattern_file/before/included_dir/a.yaml
new file mode 100644
index 0000000..0eea297
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/before/included_dir/a.yaml
@@ -0,0 +1,1 @@
+formatted:  true
diff --git a/integrationtest/command/testdata/pattern_file/before/yamlfmt.patterns b/integrationtest/command/testdata/pattern_file/before/yamlfmt.patterns
new file mode 100644
index 0000000..915fd24
--- /dev/null
+++ b/integrationtest/command/testdata/pattern_file/before/yamlfmt.patterns
@@ -0,0 +1,7 @@
+# vim: syntax=gitignore
+
+*.yaml
+*.yml
+
+!excluded_file.yaml
+!excluded_dir/
diff --git a/integrationtest/command/testdata/pattern_file/stdout/stderr.txt b/integrationtest/command/testdata/pattern_file/stdout/stderr.txt
new file mode 100644
index 0000000..e69de29
diff --git a/integrationtest/command/testdata/pattern_file/stdout/stdout.txt b/integrationtest/command/testdata/pattern_file/stdout/stdout.txt
new file mode 100644
index 0000000..e69de29
diff --git a/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt b/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt
index 2a6fae2..a1172e5 100644
--- a/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt
+++ b/integrationtest/command/testdata/print_conf_file/stdout/stdout.txt
@@ -9,6 +9,7 @@ gitignore_excludes: false
 gitignore_path: .my_gitignore
 include: []
 line_ending: crlf
+match_type: doublestar
 output_format: default
 regex_exclude: []
 formatter:
diff --git a/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt b/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt
index af2ab8f..9c73e36 100644
--- a/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt
+++ b/integrationtest/command/testdata/print_conf_flags/stdout/stdout.txt
@@ -8,6 +8,7 @@ gitignore_excludes: false
 gitignore_path: .gitignore
 include: []
 line_ending: lf
+match_type: standard
 output_format: default
 regex_exclude: []
 formatter:
diff --git a/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt b/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt
index a8f4615..01be6e7 100644
--- a/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt
+++ b/integrationtest/command/testdata/print_conf_flags_and_file/stdout/stdout.txt
@@ -9,6 +9,7 @@ gitignore_excludes: false
 gitignore_path: .my_gitignore
 include: []
 line_ending: crlf
+match_type: doublestar
 output_format: default
 regex_exclude: []
 formatter:
diff --git a/internal/jsonschema/schema_test.go b/internal/jsonschema/schema_test.go
index 59eb453..5fcbd15 100644
--- a/internal/jsonschema/schema_test.go
+++ b/internal/jsonschema/schema_test.go
@@ -17,7 +17,7 @@ package jsonschema_test
 import (
 	"testing"
 
-	"github.com/santhosh-tekuri/jsonschema/v4"
+	"github.com/santhosh-tekuri/jsonschema/v6"
 )
 
 func TestJSONSchemaIsValid(t *testing.T) {
diff --git a/path_collector_test.go b/path_collector_test.go
index 0ccf06e..0c79e45 100644
--- a/path_collector_test.go
+++ b/path_collector_test.go
@@ -15,12 +15,16 @@
 package yamlfmt_test
 
 import (
+	"bytes"
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
 	"testing"
+	"testing/fstest"
 
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/google/yamlfmt"
 	"github.com/google/yamlfmt/internal/collections"
 	"github.com/google/yamlfmt/internal/tempfile"
@@ -449,3 +453,88 @@ func useDoublestarCollector(tc testCase, path string) yamlfmt.PathCollector {
 		Exclude: tc.excludePatterns.allPatterns(path),
 	}
 }
+
+func TestPatternFile(t *testing.T) {
+	t.Parallel()
+
+	makePatterns := func(patterns ...string) []byte {
+		var b bytes.Buffer
+
+		fmt.Fprintln(&b, "# Comment followed by empty line")
+		fmt.Fprintln(&b)
+		for _, p := range patterns {
+			fmt.Fprintln(&b, p)
+		}
+
+		return b.Bytes()
+	}
+
+	cases := []struct {
+		name      string
+		patterns  []byte
+		haveFiles []string
+		wantFiles []string
+	}{
+		{
+			name:      "yaml and yml files",
+			patterns:  makePatterns("*.yaml", "*.yml"),
+			haveFiles: []string{"x.yaml", "y.yml", "README.md"},
+			wantFiles: []string{"x.yaml", "y.yml"},
+		},
+		{
+			name:      "ignore pattern",
+			patterns:  makePatterns("*.yaml", "*.yml", "!test_input.yaml"),
+			haveFiles: []string{"x.yaml", "y.yml", "test_input.yaml"},
+			wantFiles: []string{"x.yaml", "y.yml"},
+		},
+		{
+			name:      "descent into directories",
+			patterns:  makePatterns("*.yaml", "*.yml"),
+			haveFiles: []string{"a/x.yaml", "b/y.yml"},
+			wantFiles: []string{"a/x.yaml", "b/y.yml"},
+		},
+		{
+			name:      "exclude directories",
+			patterns:  makePatterns("*.yaml", "*.yml", "!a/"),
+			haveFiles: []string{"a/x.yaml", "b/y.yml"},
+			wantFiles: []string{"b/y.yml"},
+		},
+		{
+			name:      "matches are rooted at the working directory",
+			patterns:  makePatterns("*.yaml", "!/x.yaml"),
+			haveFiles: []string{"x.yaml", "a/x.yaml"},
+			wantFiles: []string{"a/x.yaml"},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			fs := make(fstest.MapFS)
+			for _, f := range tc.haveFiles {
+				fs[f] = &fstest.MapFile{
+					Data: []byte("test"),
+				}
+			}
+
+			patternFile := yamlfmt.NewPatternFileCollectorFS(bytes.NewReader(tc.patterns), fs)
+
+			gotFiles, err := patternFile.CollectPaths()
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Ignore the order of files in tc.wantFiles and gotFiles.
+			opts := []cmp.Option{
+				cmpopts.SortSlices(func(a, b string) bool { return a < b }),
+			}
+
+			if diff := cmp.Diff(tc.wantFiles, gotFiles, opts...); diff != "" {
+				t.Errorf("PatternFile.CollectPaths() differs (-want/+got):\n%s", diff)
+			}
+		})
+	}
+}