Repository: restic/restic (go)

Issue:
Restic restore include-file/exclude-file switches
Output of `restic version`
--------------------------

restic 0.16.4 compiled with go1.21.6 on darwin/amd64


What should restic do differently? Which functionality do you think we should add?
----------------------------------------------------------------------------------

It would be helpful if the include-file, --iinclude-file, --exclude-file, and --iexclude-file switches existed for the "restore" function.


What are you trying to do? What problem would this solve?
---------------------------------------------------------

I recently needed to restore a bunch of different files that got deleted.  About 1600 of them, in random places.  When I did "restic diff" I could easily get a list of all the files that were deleted - but I don't have an easy way to feed that into Restic to restore just those files.

My options are to restore a large swath of files to get a few, or to run restic restore on each file individually - which seems impractical with that many files.


Did restic help you today? Did it make you happy in any way?
------------------------------------------------------------

Restic rules!  ✌️


Pull request description:
Restore flags to include from file and exclude from file
<!--
Thank you very much for contributing code or documentation to restic! Please
fill out the following questions to make it easier for us to review your
changes.
-->

What does this PR change? What problem does it solve?
-----------------------------------------------------
Added `--exclude-file`, `--iexclude-file`, `include-file` and `iinclude-file` to the restore command. 

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
Closes #4781 

<!--
Link issues and relevant forum posts here.

If this PR resolves an issue on GitHub, use "Closes #1234" so that the issue
is closed automatically when this PR is merged.
-->

Checklist
---------

<!--
You do not need to check all the boxes below all at once. Feel free to take
your time and add more commits. If you're done and ready for review, please
check the last box. Enable a checkbox by replacing [ ] with [x].
-->

- [x] I have read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
- [x] I have [enabled maintainer edits](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork).
- [x] I have added tests for all code changes.
- [x] I have added documentation for relevant changes (in the manual).
- [x] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).
- [x] I have run `gofmt` on the code in all commits.
- [x] All commit messages are formatted in the same style as [the other commits in the repo](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
- [x] I'm done! This pull request is ready for review.


Patch:
diff --git a/changelog/unreleased/issue-4781 b/changelog/unreleased/issue-4781
new file mode 100644
index 000000000..b4af20885
--- /dev/null
+++ b/changelog/unreleased/issue-4781
@@ -0,0 +1,8 @@
+Enhancement: Add restore flags to read include and exclude patterns from files
+
+Restic now supports reading include and exclude patterns from files using the 
+`--include-file`, `--exclude-file`, `--iinclude-file` and `--iexclude-file` 
+flags.
+
+https://github.com/restic/restic/issues/4781
+https://github.com/restic/restic/pull/4811
\ No newline at end of file
diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go
index 5161be50d..12b698950 100644
--- a/cmd/restic/cmd_restore.go
+++ b/cmd/restic/cmd_restore.go
@@ -2,12 +2,10 @@ package main
 
 import (
 	"context"
-	"strings"
 	"time"
 
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
-	"github.com/restic/restic/internal/filter"
 	"github.com/restic/restic/internal/restic"
 	"github.com/restic/restic/internal/restorer"
 	"github.com/restic/restic/internal/ui"
@@ -45,11 +43,9 @@ Exit status is 0 if the command was successful, and non-zero if there was any er
 
 // RestoreOptions collects all options for the restore command.
 type RestoreOptions struct {
-	Exclude            []string
-	InsensitiveExclude []string
-	Include            []string
-	InsensitiveInclude []string
-	Target             string
+	excludePatternOptions
+	includePatternOptions
+	Target string
 	restic.SnapshotFilter
 	Sparse bool
 	Verify bool
@@ -61,12 +57,11 @@ func init() {
 	cmdRoot.AddCommand(cmdRestore)
 
 	flags := cmdRestore.Flags()
-	flags.StringArrayVarP(&restoreOptions.Exclude, "exclude", "e", nil, "exclude a `pattern` (can be specified multiple times)")
-	flags.StringArrayVar(&restoreOptions.InsensitiveExclude, "iexclude", nil, "same as --exclude but ignores the casing of `pattern`")
-	flags.StringArrayVarP(&restoreOptions.Include, "include", "i", nil, "include a `pattern`, exclude everything else (can be specified multiple times)")
-	flags.StringArrayVar(&restoreOptions.InsensitiveInclude, "iinclude", nil, "same as --include but ignores the casing of `pattern`")
 	flags.StringVarP(&restoreOptions.Target, "target", "t", "", "directory to extract data to")
 
+	initExcludePatternOptions(flags, &restoreOptions.excludePatternOptions)
+	initIncludePatternOptions(flags, &restoreOptions.includePatternOptions)
+
 	initSingleSnapshotFilter(flags, &restoreOptions.SnapshotFilter)
 	flags.BoolVar(&restoreOptions.Sparse, "sparse", false, "restore files as sparse")
 	flags.BoolVar(&restoreOptions.Verify, "verify", false, "verify restored files content")
@@ -75,38 +70,18 @@ func init() {
 func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
 	term *termstatus.Terminal, args []string) error {
 
-	hasExcludes := len(opts.Exclude) > 0 || len(opts.InsensitiveExclude) > 0
-	hasIncludes := len(opts.Include) > 0 || len(opts.InsensitiveInclude) > 0
-
-	// Validate provided patterns
-	if len(opts.Exclude) > 0 {
-		if err := filter.ValidatePatterns(opts.Exclude); err != nil {
-			return errors.Fatalf("--exclude: %s", err)
-		}
-	}
-	if len(opts.InsensitiveExclude) > 0 {
-		if err := filter.ValidatePatterns(opts.InsensitiveExclude); err != nil {
-			return errors.Fatalf("--iexclude: %s", err)
-		}
-	}
-	if len(opts.Include) > 0 {
-		if err := filter.ValidatePatterns(opts.Include); err != nil {
-			return errors.Fatalf("--include: %s", err)
-		}
-	}
-	if len(opts.InsensitiveInclude) > 0 {
-		if err := filter.ValidatePatterns(opts.InsensitiveInclude); err != nil {
-			return errors.Fatalf("--iinclude: %s", err)
-		}
+	excludePatternFns, err := opts.excludePatternOptions.CollectPatterns()
+	if err != nil {
+		return err
 	}
 
-	for i, str := range opts.InsensitiveExclude {
-		opts.InsensitiveExclude[i] = strings.ToLower(str)
+	includePatternFns, err := opts.includePatternOptions.CollectPatterns()
+	if err != nil {
+		return err
 	}
 
-	for i, str := range opts.InsensitiveInclude {
-		opts.InsensitiveInclude[i] = strings.ToLower(str)
-	}
+	hasExcludes := len(excludePatternFns) > 0
+	hasIncludes := len(includePatternFns) > 0
 
 	switch {
 	case len(args) == 0:
@@ -174,44 +149,41 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
 		msg.E("Warning: %s\n", message)
 	}
 
-	excludePatterns := filter.ParsePatterns(opts.Exclude)
-	insensitiveExcludePatterns := filter.ParsePatterns(opts.InsensitiveExclude)
 	selectExcludeFilter := func(item string, _ string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool) {
-		matched, err := filter.List(excludePatterns, item)
-		if err != nil {
-			msg.E("error for exclude pattern: %v", err)
-		}
-
-		matchedInsensitive, err := filter.List(insensitiveExcludePatterns, strings.ToLower(item))
-		if err != nil {
-			msg.E("error for iexclude pattern: %v", err)
+		matched := false
+		for _, rejectFn := range excludePatternFns {
+			matched = matched || rejectFn(item)
+
+			// implementing a short-circuit here to improve the performance
+			// to prevent additional pattern matching once the first pattern
+			// matches.
+			if matched {
+				break
+			}
 		}
-
 		// An exclude filter is basically a 'wildcard but foo',
 		// so even if a childMayMatch, other children of a dir may not,
 		// therefore childMayMatch does not matter, but we should not go down
 		// unless the dir is selected for restore
-		selectedForRestore = !matched && !matchedInsensitive
+		selectedForRestore = !matched
 		childMayBeSelected = selectedForRestore && node.Type == "dir"
 
 		return selectedForRestore, childMayBeSelected
 	}
 
-	includePatterns := filter.ParsePatterns(opts.Include)
-	insensitiveIncludePatterns := filter.ParsePatterns(opts.InsensitiveInclude)
 	selectIncludeFilter := func(item string, _ string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool) {
-		matched, childMayMatch, err := filter.ListWithChild(includePatterns, item)
-		if err != nil {
-			msg.E("error for include pattern: %v", err)
-		}
-
-		matchedInsensitive, childMayMatchInsensitive, err := filter.ListWithChild(insensitiveIncludePatterns, strings.ToLower(item))
-		if err != nil {
-			msg.E("error for iexclude pattern: %v", err)
+		selectedForRestore = false
+		childMayBeSelected = false
+		for _, includeFn := range includePatternFns {
+			matched, childMayMatch := includeFn(item)
+			selectedForRestore = selectedForRestore || matched
+			childMayBeSelected = childMayBeSelected || childMayMatch
+
+			if selectedForRestore && childMayBeSelected {
+				break
+			}
 		}
-
-		selectedForRestore = matched || matchedInsensitive
-		childMayBeSelected = (childMayMatch || childMayMatchInsensitive) && node.Type == "dir"
+		childMayBeSelected = childMayBeSelected && node.Type == "dir"
 
 		return selectedForRestore, childMayBeSelected
 	}
diff --git a/cmd/restic/exclude.go b/cmd/restic/exclude.go
index d9bb63aeb..4657e4915 100644
--- a/cmd/restic/exclude.go
+++ b/cmd/restic/exclude.go
@@ -385,12 +385,12 @@ func rejectBySize(maxSizeStr string) (RejectFunc, error) {
 	}, nil
 }
 
-// readExcludePatternsFromFiles reads all exclude files and returns the list of
-// exclude patterns. For each line, leading and trailing white space is removed
+// readPatternsFromFiles reads all files and returns the list of
+// patterns. For each line, leading and trailing white space is removed
 // and comment lines are ignored. For each remaining pattern, environment
 // variables are resolved. For adding a literal dollar sign ($), write $$ to
 // the file.
-func readExcludePatternsFromFiles(excludeFiles []string) ([]string, error) {
+func readPatternsFromFiles(files []string) ([]string, error) {
 	getenvOrDollar := func(s string) string {
 		if s == "$" {
 			return "$"
@@ -398,8 +398,8 @@ func readExcludePatternsFromFiles(excludeFiles []string) ([]string, error) {
 		return os.Getenv(s)
 	}
 
-	var excludes []string
-	for _, filename := range excludeFiles {
+	var patterns []string
+	for _, filename := range files {
 		err := func() (err error) {
 			data, err := textfile.Read(filename)
 			if err != nil {
@@ -421,15 +421,15 @@ func readExcludePatternsFromFiles(excludeFiles []string) ([]string, error) {
 				}
 
 				line = os.Expand(line, getenvOrDollar)
-				excludes = append(excludes, line)
+				patterns = append(patterns, line)
 			}
 			return scanner.Err()
 		}()
 		if err != nil {
-			return nil, fmt.Errorf("failed to read excludes from file %q: %w", filename, err)
+			return nil, fmt.Errorf("failed to read patterns from file %q: %w", filename, err)
 		}
 	}
-	return excludes, nil
+	return patterns, nil
 }
 
 type excludePatternOptions struct {
@@ -454,7 +454,7 @@ func (opts excludePatternOptions) CollectPatterns() ([]RejectByNameFunc, error)
 	var fs []RejectByNameFunc
 	// add patterns from file
 	if len(opts.ExcludeFiles) > 0 {
-		excludePatterns, err := readExcludePatternsFromFiles(opts.ExcludeFiles)
+		excludePatterns, err := readPatternsFromFiles(opts.ExcludeFiles)
 		if err != nil {
 			return nil, err
 		}
@@ -467,7 +467,7 @@ func (opts excludePatternOptions) CollectPatterns() ([]RejectByNameFunc, error)
 	}
 
 	if len(opts.InsensitiveExcludeFiles) > 0 {
-		excludes, err := readExcludePatternsFromFiles(opts.InsensitiveExcludeFiles)
+		excludes, err := readPatternsFromFiles(opts.InsensitiveExcludeFiles)
 		if err != nil {
 			return nil, err
 		}
diff --git a/cmd/restic/include.go b/cmd/restic/include.go
new file mode 100644
index 000000000..dcc4c7f37
--- /dev/null
+++ b/cmd/restic/include.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+	"strings"
+
+	"github.com/restic/restic/internal/errors"
+	"github.com/restic/restic/internal/filter"
+	"github.com/spf13/pflag"
+)
+
+// IncludeByNameFunc is a function that takes a filename that should be included
+// in the restore process and returns whether it should be included.
+type IncludeByNameFunc func(item string) (matched bool, childMayMatch bool)
+
+type includePatternOptions struct {
+	Includes                []string
+	InsensitiveIncludes     []string
+	IncludeFiles            []string
+	InsensitiveIncludeFiles []string
+}
+
+func initIncludePatternOptions(f *pflag.FlagSet, opts *includePatternOptions) {
+	f.StringArrayVarP(&opts.Includes, "include", "i", nil, "include a `pattern` (can be specified multiple times)")
+	f.StringArrayVar(&opts.InsensitiveIncludes, "iinclude", nil, "same as --include `pattern` but ignores the casing of filenames")
+	f.StringArrayVar(&opts.IncludeFiles, "include-file", nil, "read include patterns from a `file` (can be specified multiple times)")
+	f.StringArrayVar(&opts.InsensitiveIncludeFiles, "iinclude-file", nil, "same as --include-file but ignores casing of `file`names in patterns")
+}
+
+func (opts includePatternOptions) CollectPatterns() ([]IncludeByNameFunc, error) {
+	var fs []IncludeByNameFunc
+	if len(opts.IncludeFiles) > 0 {
+		includePatterns, err := readPatternsFromFiles(opts.IncludeFiles)
+		if err != nil {
+			return nil, err
+		}
+
+		if err := filter.ValidatePatterns(includePatterns); err != nil {
+			return nil, errors.Fatalf("--include-file: %s", err)
+		}
+
+		opts.Includes = append(opts.Includes, includePatterns...)
+	}
+
+	if len(opts.InsensitiveIncludeFiles) > 0 {
+		includePatterns, err := readPatternsFromFiles(opts.InsensitiveIncludeFiles)
+		if err != nil {
+			return nil, err
+		}
+
+		if err := filter.ValidatePatterns(includePatterns); err != nil {
+			return nil, errors.Fatalf("--iinclude-file: %s", err)
+		}
+
+		opts.InsensitiveIncludes = append(opts.InsensitiveIncludes, includePatterns...)
+	}
+
+	if len(opts.InsensitiveIncludes) > 0 {
+		if err := filter.ValidatePatterns(opts.InsensitiveIncludes); err != nil {
+			return nil, errors.Fatalf("--iinclude: %s", err)
+		}
+
+		fs = append(fs, includeByInsensitivePattern(opts.InsensitiveIncludes))
+	}
+
+	if len(opts.Includes) > 0 {
+		if err := filter.ValidatePatterns(opts.Includes); err != nil {
+			return nil, errors.Fatalf("--include: %s", err)
+		}
+
+		fs = append(fs, includeByPattern(opts.Includes))
+	}
+	return fs, nil
+}
+
+// includeByPattern returns a IncludeByNameFunc which includes files that match
+// one of the patterns.
+func includeByPattern(patterns []string) IncludeByNameFunc {
+	parsedPatterns := filter.ParsePatterns(patterns)
+	return func(item string) (matched bool, childMayMatch bool) {
+		matched, childMayMatch, err := filter.ListWithChild(parsedPatterns, item)
+		if err != nil {
+			Warnf("error for include pattern: %v", err)
+		}
+
+		return matched, childMayMatch
+	}
+}
+
+// includeByInsensitivePattern returns a IncludeByNameFunc which includes files that match
+// one of the patterns, ignoring the casing of the filenames.
+func includeByInsensitivePattern(patterns []string) IncludeByNameFunc {
+	for index, path := range patterns {
+		patterns[index] = strings.ToLower(path)
+	}
+
+	includeFunc := includeByPattern(patterns)
+	return func(item string) (matched bool, childMayMatch bool) {
+		return includeFunc(strings.ToLower(item))
+	}
+}
diff --git a/doc/050_restore.rst b/doc/050_restore.rst
index ce17a1cf7..5fc94c145 100644
--- a/doc/050_restore.rst
+++ b/doc/050_restore.rst
@@ -68,6 +68,9 @@ There are case insensitive variants of ``--exclude`` and ``--include`` called
 ``--iexclude`` and ``--iinclude``. These options will behave the same way but
 ignore the casing of paths.
 
+There are also ``--include-file``, ``--exclude-file``, ``--iinclude-file`` and
+ ``--iexclude-file`` flags that read the include and exclude patterns from a file.
+
 Restoring symbolic links on windows is only possible when the user has
 ``SeCreateSymbolicLinkPrivilege`` privilege or is running as admin. This is a
 restriction of windows not restic.


Test patch:
diff --git a/cmd/restic/cmd_restore_integration_test.go b/cmd/restic/cmd_restore_integration_test.go
index 8da6f522a..b0543850b 100644
--- a/cmd/restic/cmd_restore_integration_test.go
+++ b/cmd/restic/cmd_restore_integration_test.go
@@ -7,12 +7,12 @@ import (
 	"math/rand"
 	"os"
 	"path/filepath"
+	"strings"
 	"syscall"
 	"testing"
 	"time"
 
 	"github.com/restic/restic/internal/feature"
-	"github.com/restic/restic/internal/filter"
 	"github.com/restic/restic/internal/restic"
 	rtest "github.com/restic/restic/internal/test"
 	"github.com/restic/restic/internal/ui/termstatus"
@@ -24,9 +24,9 @@ func testRunRestore(t testing.TB, opts GlobalOptions, dir string, snapshotID res
 
 func testRunRestoreExcludes(t testing.TB, gopts GlobalOptions, dir string, snapshotID restic.ID, excludes []string) {
 	opts := RestoreOptions{
-		Target:  dir,
-		Exclude: excludes,
+		Target: dir,
 	}
+	opts.Excludes = excludes
 
 	rtest.OK(t, testRunRestoreAssumeFailure(snapshotID.String(), opts, gopts))
 }
@@ -51,22 +51,132 @@ func testRunRestoreLatest(t testing.TB, gopts GlobalOptions, dir string, paths [
 
 func testRunRestoreIncludes(t testing.TB, gopts GlobalOptions, dir string, snapshotID restic.ID, includes []string) {
 	opts := RestoreOptions{
-		Target:  dir,
-		Include: includes,
+		Target: dir,
+	}
+	opts.Includes = includes
+
+	rtest.OK(t, testRunRestoreAssumeFailure(snapshotID.String(), opts, gopts))
+}
+
+func testRunRestoreIncludesFromFile(t testing.TB, gopts GlobalOptions, dir string, snapshotID restic.ID, includesFile string) {
+	opts := RestoreOptions{
+		Target: dir,
+	}
+	opts.IncludeFiles = []string{includesFile}
+
+	rtest.OK(t, testRunRestoreAssumeFailure(snapshotID.String(), opts, gopts))
+}
+
+func testRunRestoreExcludesFromFile(t testing.TB, gopts GlobalOptions, dir string, snapshotID restic.ID, excludesFile string) {
+	opts := RestoreOptions{
+		Target: dir,
 	}
+	opts.ExcludeFiles = []string{excludesFile}
 
 	rtest.OK(t, testRunRestoreAssumeFailure(snapshotID.String(), opts, gopts))
 }
 
+func TestRestoreMustFailWhenUsingBothIncludesAndExcludes(t *testing.T) {
+	env, cleanup := withTestEnvironment(t)
+	defer cleanup()
+
+	testRunInit(t, env.gopts)
+
+	// Add both include and exclude patterns
+	includePatterns := []string{"dir1/*include_me.txt", "dir2/**", "dir4/**/*_me.txt"}
+	excludePatterns := []string{"dir1/*include_me.txt", "dir2/**", "dir4/**/*_me.txt"}
+
+	restoredir := filepath.Join(env.base, "restore")
+
+	restoreOpts := RestoreOptions{
+		Target: restoredir,
+	}
+	restoreOpts.Includes = includePatterns
+	restoreOpts.Excludes = excludePatterns
+
+	err := testRunRestoreAssumeFailure("latest", restoreOpts, env.gopts)
+	rtest.Assert(t, err != nil && strings.Contains(err.Error(), "exclude and include patterns are mutually exclusive"),
+		"expected: %s error, got %v", "exclude and include patterns are mutually exclusive", err)
+}
+
+func TestRestoreIncludes(t *testing.T) {
+	testfiles := []struct {
+		path    string
+		size    uint
+		include bool // Whether this file should be included in the restore
+	}{
+		{"dir1/include_me.txt", 100, true},
+		{"dir1/something_else.txt", 200, false},
+		{"dir2/also_include_me.txt", 150, true},
+		{"dir2/important_file.txt", 150, true},
+		{"dir3/not_included.txt", 180, false},
+		{"dir4/subdir/should_include_me.txt", 120, true},
+	}
+
+	env, cleanup := withTestEnvironment(t)
+	defer cleanup()
+
+	testRunInit(t, env.gopts)
+
+	// Create test files and directories
+	for _, testFile := range testfiles {
+		fullPath := filepath.Join(env.testdata, testFile.path)
+		rtest.OK(t, os.MkdirAll(filepath.Dir(fullPath), 0755))
+		rtest.OK(t, appendRandomData(fullPath, testFile.size))
+	}
+
+	opts := BackupOptions{}
+
+	// Perform backup
+	testRunBackup(t, filepath.Dir(env.testdata), []string{filepath.Base(env.testdata)}, opts, env.gopts)
+	testRunCheck(t, env.gopts)
+
+	snapshotID := testListSnapshots(t, env.gopts, 1)[0]
+
+	// Restore using includes
+	includePatterns := []string{"dir1/*include_me.txt", "dir2/**", "dir4/**/*_me.txt"}
+	restoredir := filepath.Join(env.base, "restore")
+	testRunRestoreIncludes(t, env.gopts, restoredir, snapshotID, includePatterns)
+
+	testRestoreFileInclusions := func(t *testing.T) {
+		// Check that only the included files are restored
+		for _, testFile := range testfiles {
+			restoredFilePath := filepath.Join(restoredir, "testdata", testFile.path)
+			_, err := os.Stat(restoredFilePath)
+			if testFile.include {
+				rtest.OK(t, err)
+			} else {
+				rtest.Assert(t, os.IsNotExist(err), "File %s should not have been restored", testFile.path)
+			}
+		}
+	}
+
+	testRestoreFileInclusions(t)
+
+	// Create an include file with some patterns
+	patternsFile := env.base + "/patternsFile"
+	fileErr := os.WriteFile(patternsFile, []byte(strings.Join(includePatterns, "\n")), 0644)
+	if fileErr != nil {
+		t.Fatalf("Could not write include file: %v", fileErr)
+	}
+
+	restoredir = filepath.Join(env.base, "restore-include-from-file")
+
+	testRunRestoreIncludesFromFile(t, env.gopts, restoredir, snapshotID, patternsFile)
+
+	testRestoreFileInclusions(t)
+}
+
 func TestRestoreFilter(t *testing.T) {
 	testfiles := []struct {
-		name string
-		size uint
+		name    string
+		size    uint
+		exclude bool
 	}{
-		{"testfile1.c", 100},
-		{"testfile2.exe", 101},
-		{"subdir1/subdir2/testfile3.docx", 102},
-		{"subdir1/subdir2/testfile4.c", 102},
+		{"testfile1.c", 100, true},
+		{"testfile2.exe", 101, true},
+		{"subdir1/subdir2/testfile3.docx", 102, true},
+		{"subdir1/subdir2/testfile4.c", 102, false},
 	}
 
 	env, cleanup := withTestEnvironment(t)
@@ -93,19 +203,38 @@ func TestRestoreFilter(t *testing.T) {
 		rtest.OK(t, testFileSize(filepath.Join(env.base, "restore0", "testdata", testFile.name), int64(testFile.size)))
 	}
 
-	for i, pat := range []string{"*.c", "*.exe", "*", "*file3*"} {
-		base := filepath.Join(env.base, fmt.Sprintf("restore%d", i+1))
-		testRunRestoreExcludes(t, env.gopts, base, snapshotID, []string{pat})
+	excludePatterns := []string{"testfile1.c", "*.exe", "*file3*"}
+
+	// checks if the files are excluded correctly
+	testRestoredFileExclusions := func(t *testing.T, restoredir string) {
 		for _, testFile := range testfiles {
-			err := testFileSize(filepath.Join(base, "testdata", testFile.name), int64(testFile.size))
-			if ok, _ := filter.Match(pat, filepath.Base(testFile.name)); !ok {
-				rtest.OK(t, err)
+			restoredFilePath := filepath.Join(restoredir, "testdata", testFile.name)
+			_, err := os.Stat(restoredFilePath)
+			if testFile.exclude {
+				rtest.Assert(t, os.IsNotExist(err), "File %s should not have been restored", testFile.name)
 			} else {
-				rtest.Assert(t, os.IsNotExist(err),
-					"expected %v to not exist in restore step %v, but it exists, err %v", testFile.name, i+1, err)
+				rtest.OK(t, testFileSize(restoredFilePath, int64(testFile.size)))
 			}
 		}
 	}
+
+	// restore with excludes
+	restoredir := filepath.Join(env.base, "restore-with-excludes")
+	testRunRestoreExcludes(t, env.gopts, restoredir, snapshotID, excludePatterns)
+	testRestoredFileExclusions(t, restoredir)
+
+	// Create an exclude file with some patterns
+	patternsFile := env.base + "/patternsFile"
+	fileErr := os.WriteFile(patternsFile, []byte(strings.Join(excludePatterns, "\n")), 0644)
+	if fileErr != nil {
+		t.Fatalf("Could not write include file: %v", fileErr)
+	}
+
+	// restore with excludes from file
+	restoredir = filepath.Join(env.base, "restore-with-exclude-from-file")
+	testRunRestoreExcludesFromFile(t, env.gopts, restoredir, snapshotID, patternsFile)
+
+	testRestoredFileExclusions(t, restoredir)
 }
 
 func TestRestore(t *testing.T) {
diff --git a/cmd/restic/include_test.go b/cmd/restic/include_test.go
new file mode 100644
index 000000000..751bfbb76
--- /dev/null
+++ b/cmd/restic/include_test.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestIncludeByPattern(t *testing.T) {
+	var tests = []struct {
+		filename string
+		include  bool
+	}{
+		{filename: "/home/user/foo.go", include: true},
+		{filename: "/home/user/foo.c", include: false},
+		{filename: "/home/user/foobar", include: false},
+		{filename: "/home/user/foobar/x", include: false},
+		{filename: "/home/user/README", include: false},
+		{filename: "/home/user/README.md", include: true},
+	}
+
+	patterns := []string{"*.go", "README.md"}
+
+	for _, tc := range tests {
+		t.Run(tc.filename, func(t *testing.T) {
+			includeFunc := includeByPattern(patterns)
+			matched, _ := includeFunc(tc.filename)
+			if matched != tc.include {
+				t.Fatalf("wrong result for filename %v: want %v, got %v",
+					tc.filename, tc.include, matched)
+			}
+		})
+	}
+}
+
+func TestIncludeByInsensitivePattern(t *testing.T) {
+	var tests = []struct {
+		filename string
+		include  bool
+	}{
+		{filename: "/home/user/foo.GO", include: true},
+		{filename: "/home/user/foo.c", include: false},
+		{filename: "/home/user/foobar", include: false},
+		{filename: "/home/user/FOObar/x", include: false},
+		{filename: "/home/user/README", include: false},
+		{filename: "/home/user/readme.MD", include: true},
+	}
+
+	patterns := []string{"*.go", "README.md"}
+
+	for _, tc := range tests {
+		t.Run(tc.filename, func(t *testing.T) {
+			includeFunc := includeByInsensitivePattern(patterns)
+			matched, _ := includeFunc(tc.filename)
+			if matched != tc.include {
+				t.Fatalf("wrong result for filename %v: want %v, got %v",
+					tc.filename, tc.include, matched)
+			}
+		})
+	}
+}
diff --git a/cmd/restic/integration_filter_pattern_test.go b/cmd/restic/integration_filter_pattern_test.go
index 2eacdeea9..dccbcc0a0 100644
--- a/cmd/restic/integration_filter_pattern_test.go
+++ b/cmd/restic/integration_filter_pattern_test.go
@@ -70,30 +70,64 @@ func TestRestoreFailsWhenUsingInvalidPatterns(t *testing.T) {
 	var err error
 
 	// Test --exclude
-	err = testRunRestoreAssumeFailure("latest", RestoreOptions{Exclude: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}, env.gopts)
+	err = testRunRestoreAssumeFailure("latest", RestoreOptions{excludePatternOptions: excludePatternOptions{Excludes: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}}, env.gopts)
 
 	rtest.Equals(t, `Fatal: --exclude: invalid pattern(s) provided:
 *[._]log[.-][0-9]
 !*[._]log[.-][0-9]`, err.Error())
 
 	// Test --iexclude
-	err = testRunRestoreAssumeFailure("latest", RestoreOptions{InsensitiveExclude: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}, env.gopts)
+	err = testRunRestoreAssumeFailure("latest", RestoreOptions{excludePatternOptions: excludePatternOptions{InsensitiveExcludes: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}}, env.gopts)
 
 	rtest.Equals(t, `Fatal: --iexclude: invalid pattern(s) provided:
 *[._]log[.-][0-9]
 !*[._]log[.-][0-9]`, err.Error())
 
 	// Test --include
-	err = testRunRestoreAssumeFailure("latest", RestoreOptions{Include: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}, env.gopts)
+	err = testRunRestoreAssumeFailure("latest", RestoreOptions{includePatternOptions: includePatternOptions{Includes: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}}, env.gopts)
 
 	rtest.Equals(t, `Fatal: --include: invalid pattern(s) provided:
 *[._]log[.-][0-9]
 !*[._]log[.-][0-9]`, err.Error())
 
 	// Test --iinclude
-	err = testRunRestoreAssumeFailure("latest", RestoreOptions{InsensitiveInclude: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}, env.gopts)
+	err = testRunRestoreAssumeFailure("latest", RestoreOptions{includePatternOptions: includePatternOptions{InsensitiveIncludes: []string{"*[._]log[.-][0-9]", "!*[._]log[.-][0-9]"}}}, env.gopts)
 
 	rtest.Equals(t, `Fatal: --iinclude: invalid pattern(s) provided:
 *[._]log[.-][0-9]
 !*[._]log[.-][0-9]`, err.Error())
 }
+
+func TestRestoreFailsWhenUsingInvalidPatternsFromFile(t *testing.T) {
+	env, cleanup := withTestEnvironment(t)
+	defer cleanup()
+
+	testRunInit(t, env.gopts)
+
+	// Create an include file with some invalid patterns
+	patternsFile := env.base + "/patternsFile"
+	fileErr := os.WriteFile(patternsFile, []byte("*.go\n*[._]log[.-][0-9]\n!*[._]log[.-][0-9]"), 0644)
+	if fileErr != nil {
+		t.Fatalf("Could not write include file: %v", fileErr)
+	}
+
+	err := testRunRestoreAssumeFailure("latest", RestoreOptions{includePatternOptions: includePatternOptions{IncludeFiles: []string{patternsFile}}}, env.gopts)
+	rtest.Equals(t, `Fatal: --include-file: invalid pattern(s) provided:
+*[._]log[.-][0-9]
+!*[._]log[.-][0-9]`, err.Error())
+
+	err = testRunRestoreAssumeFailure("latest", RestoreOptions{excludePatternOptions: excludePatternOptions{ExcludeFiles: []string{patternsFile}}}, env.gopts)
+	rtest.Equals(t, `Fatal: --exclude-file: invalid pattern(s) provided:
+*[._]log[.-][0-9]
+!*[._]log[.-][0-9]`, err.Error())
+
+	err = testRunRestoreAssumeFailure("latest", RestoreOptions{includePatternOptions: includePatternOptions{InsensitiveIncludeFiles: []string{patternsFile}}}, env.gopts)
+	rtest.Equals(t, `Fatal: --iinclude-file: invalid pattern(s) provided:
+*[._]log[.-][0-9]
+!*[._]log[.-][0-9]`, err.Error())
+
+	err = testRunRestoreAssumeFailure("latest", RestoreOptions{excludePatternOptions: excludePatternOptions{InsensitiveExcludeFiles: []string{patternsFile}}}, env.gopts)
+	rtest.Equals(t, `Fatal: --iexclude-file: invalid pattern(s) provided:
+*[._]log[.-][0-9]
+!*[._]log[.-][0-9]`, err.Error())
+}