Repository: restic/restic
Pull request #5241 (2025-02-07 19:35:12)

Refactor CLI command initialization to use less global state
<!--
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?
-----------------------------------------------------
Refactor the CLI command setup to a better testable pattern with much less global state. The main changes are as follows:
- Each options struct now has a `AddFlags(f *pflag.FlagSet)` method that is used to add flags to a command
- Commands are created using  `new<NAME>Command()`  functions. The options for this command are enclosed in the closure. This removes the global options variables for every command.
- The root command now explicitly constructs all child commands. This will allow returning errors later on.
- globalOptions still exists as it is used in several global functions. Most prominently the Print and Verbose functions.
- Commands that are gated by build tags now use a `register<NAME>Command(cmd)` function that is a no-op when the command is not enabled. As an additional cleanup the profiling features are now implemented by wrapping the Pre and PostRun functions.

<!--
Describe the changes and their purpose here, as detailed as needed.
-->

The new patterns also will allow returning errors if environment variables contain errors etc. I've not made any such changes as the PR is already for too large.

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
No. Suggested in https://github.com/restic/restic/issues/4759#issuecomment-2131275416
<!--
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].

Please always follow these steps:
- Read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
- Enable [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).
- Run `gofmt` on the code in all commits.
- Format all commit messages in the same style as [the other commits in the repository](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
-->

- ~~[ ] I have added tests for all code changes.~~
- ~~[ ] I have added documentation for relevant changes (in the manual).~~
- ~~[ ] 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)).~~ Not yet user visible. Any visible change is a bug.
- [x] I'm done! This pull request is ready for review.



Patch:
diff --git a/cmd/restic/cmd_backup.go b/cmd/restic/cmd_backup.go
index 93b4556c7..70c0d2fb9 100644
--- a/cmd/restic/cmd_backup.go
+++ b/cmd/restic/cmd_backup.go
@@ -15,6 +15,7 @@ import (
 	"time"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 	"golang.org/x/sync/errgroup"
 
 	"github.com/restic/restic/internal/archiver"
@@ -30,10 +31,13 @@ import (
 	"github.com/restic/restic/internal/ui/termstatus"
 )
 
-var cmdBackup = &cobra.Command{
-	Use:   "backup [flags] [FILE/DIR] ...",
-	Short: "Create a new backup of files and/or directories",
-	Long: `
+func newBackupCommand() *cobra.Command {
+	var opts BackupOptions
+
+	cmd := &cobra.Command{
+		Use:   "backup [flags] [FILE/DIR] ...",
+		Short: "Create a new backup of files and/or directories",
+		Long: `
 The "backup" command creates a new snapshot and saves the files and directories
 given as the arguments.
 
@@ -47,23 +51,27 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	PreRun: func(_ *cobra.Command, _ []string) {
-		if backupOptions.Host == "" {
-			hostname, err := os.Hostname()
-			if err != nil {
-				debug.Log("os.Hostname() returned err: %v", err)
-				return
+		PreRun: func(_ *cobra.Command, _ []string) {
+			if opts.Host == "" {
+				hostname, err := os.Hostname()
+				if err != nil {
+					debug.Log("os.Hostname() returned err: %v", err)
+					return
+				}
+				opts.Host = hostname
 			}
-			backupOptions.Host = hostname
-		}
-	},
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runBackup(cmd.Context(), backupOptions, globalOptions, term, args)
-	},
+		},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runBackup(cmd.Context(), opts, globalOptions, term, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // BackupOptions bundles all options for the backup command.
@@ -97,64 +105,60 @@ type BackupOptions struct {
 	SkipIfUnchanged   bool
 }
 
-var backupOptions BackupOptions
-var backupFSTestHook func(fs fs.FS) fs.FS
-
-// ErrInvalidSourceData is used to report an incomplete backup
-var ErrInvalidSourceData = errors.New("at least one source file could not be read")
-
-func init() {
-	cmdRoot.AddCommand(cmdBackup)
-
-	f := cmdBackup.Flags()
-	f.StringVar(&backupOptions.Parent, "parent", "", "use this parent `snapshot` (default: latest snapshot in the group determined by --group-by and not newer than the timestamp determined by --time)")
-	backupOptions.GroupBy = restic.SnapshotGroupByOptions{Host: true, Path: true}
-	f.VarP(&backupOptions.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma (disable grouping with '')")
-	f.BoolVarP(&backupOptions.Force, "force", "f", false, `force re-reading the source files/directories (overrides the "parent" flag)`)
-
-	backupOptions.ExcludePatternOptions.Add(f)
-
-	f.BoolVarP(&backupOptions.ExcludeOtherFS, "one-file-system", "x", false, "exclude other file systems, don't cross filesystem boundaries and subvolumes")
-	f.StringArrayVar(&backupOptions.ExcludeIfPresent, "exclude-if-present", nil, "takes `filename[:header]`, exclude contents of directories containing filename (except filename itself) if header of that file is as provided (can be specified multiple times)")
-	f.BoolVar(&backupOptions.ExcludeCaches, "exclude-caches", false, `excludes cache directories that are marked with a CACHEDIR.TAG file. See https://bford.info/cachedir/ for the Cache Directory Tagging Standard`)
-	f.StringVar(&backupOptions.ExcludeLargerThan, "exclude-larger-than", "", "max `size` of the files to be backed up (allowed suffixes: k/K, m/M, g/G, t/T)")
-	f.BoolVar(&backupOptions.Stdin, "stdin", false, "read backup from stdin")
-	f.StringVar(&backupOptions.StdinFilename, "stdin-filename", "stdin", "`filename` to use when reading from stdin")
-	f.BoolVar(&backupOptions.StdinCommand, "stdin-from-command", false, "interpret arguments as command to execute and store its stdout")
-	f.Var(&backupOptions.Tags, "tag", "add `tags` for the new snapshot in the format `tag[,tag,...]` (can be specified multiple times)")
-	f.UintVar(&backupOptions.ReadConcurrency, "read-concurrency", 0, "read `n` files concurrently (default: $RESTIC_READ_CONCURRENCY or 2)")
-	f.StringVarP(&backupOptions.Host, "host", "H", "", "set the `hostname` for the snapshot manually (default: $RESTIC_HOST). To prevent an expensive rescan use the \"parent\" flag")
-	f.StringVar(&backupOptions.Host, "hostname", "", "set the `hostname` for the snapshot manually")
+func (opts *BackupOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVar(&opts.Parent, "parent", "", "use this parent `snapshot` (default: latest snapshot in the group determined by --group-by and not newer than the timestamp determined by --time)")
+	opts.GroupBy = restic.SnapshotGroupByOptions{Host: true, Path: true}
+	f.VarP(&opts.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma (disable grouping with '')")
+	f.BoolVarP(&opts.Force, "force", "f", false, `force re-reading the source files/directories (overrides the "parent" flag)`)
+
+	opts.ExcludePatternOptions.Add(f)
+
+	f.BoolVarP(&opts.ExcludeOtherFS, "one-file-system", "x", false, "exclude other file systems, don't cross filesystem boundaries and subvolumes")
+	f.StringArrayVar(&opts.ExcludeIfPresent, "exclude-if-present", nil, "takes `filename[:header]`, exclude contents of directories containing filename (except filename itself) if header of that file is as provided (can be specified multiple times)")
+	f.BoolVar(&opts.ExcludeCaches, "exclude-caches", false, `excludes cache directories that are marked with a CACHEDIR.TAG file. See https://bford.info/cachedir/ for the Cache Directory Tagging Standard`)
+	f.StringVar(&opts.ExcludeLargerThan, "exclude-larger-than", "", "max `size` of the files to be backed up (allowed suffixes: k/K, m/M, g/G, t/T)")
+	f.BoolVar(&opts.Stdin, "stdin", false, "read backup from stdin")
+	f.StringVar(&opts.StdinFilename, "stdin-filename", "stdin", "`filename` to use when reading from stdin")
+	f.BoolVar(&opts.StdinCommand, "stdin-from-command", false, "interpret arguments as command to execute and store its stdout")
+	f.Var(&opts.Tags, "tag", "add `tags` for the new snapshot in the format `tag[,tag,...]` (can be specified multiple times)")
+	f.UintVar(&opts.ReadConcurrency, "read-concurrency", 0, "read `n` files concurrently (default: $RESTIC_READ_CONCURRENCY or 2)")
+	f.StringVarP(&opts.Host, "host", "H", "", "set the `hostname` for the snapshot manually (default: $RESTIC_HOST). To prevent an expensive rescan use the \"parent\" flag")
+	f.StringVar(&opts.Host, "hostname", "", "set the `hostname` for the snapshot manually")
 	err := f.MarkDeprecated("hostname", "use --host")
 	if err != nil {
 		// MarkDeprecated only returns an error when the flag could not be found
 		panic(err)
 	}
-	f.StringArrayVar(&backupOptions.FilesFrom, "files-from", nil, "read the files to backup from `file` (can be combined with file args; can be specified multiple times)")
-	f.StringArrayVar(&backupOptions.FilesFromVerbatim, "files-from-verbatim", nil, "read the files to backup from `file` (can be combined with file args; can be specified multiple times)")
-	f.StringArrayVar(&backupOptions.FilesFromRaw, "files-from-raw", nil, "read the files to backup from `file` (can be combined with file args; can be specified multiple times)")
-	f.StringVar(&backupOptions.TimeStamp, "time", "", "`time` of the backup (ex. '2012-11-01 22:08:41') (default: now)")
-	f.BoolVar(&backupOptions.WithAtime, "with-atime", false, "store the atime for all files and directories")
-	f.BoolVar(&backupOptions.IgnoreInode, "ignore-inode", false, "ignore inode number and ctime changes when checking for modified files")
-	f.BoolVar(&backupOptions.IgnoreCtime, "ignore-ctime", false, "ignore ctime changes when checking for modified files")
-	f.BoolVarP(&backupOptions.DryRun, "dry-run", "n", false, "do not upload or write any data, just show what would be done")
-	f.BoolVar(&backupOptions.NoScan, "no-scan", false, "do not run scanner to estimate size of backup")
+	f.StringArrayVar(&opts.FilesFrom, "files-from", nil, "read the files to backup from `file` (can be combined with file args; can be specified multiple times)")
+	f.StringArrayVar(&opts.FilesFromVerbatim, "files-from-verbatim", nil, "read the files to backup from `file` (can be combined with file args; can be specified multiple times)")
+	f.StringArrayVar(&opts.FilesFromRaw, "files-from-raw", nil, "read the files to backup from `file` (can be combined with file args; can be specified multiple times)")
+	f.StringVar(&opts.TimeStamp, "time", "", "`time` of the backup (ex. '2012-11-01 22:08:41') (default: now)")
+	f.BoolVar(&opts.WithAtime, "with-atime", false, "store the atime for all files and directories")
+	f.BoolVar(&opts.IgnoreInode, "ignore-inode", false, "ignore inode number and ctime changes when checking for modified files")
+	f.BoolVar(&opts.IgnoreCtime, "ignore-ctime", false, "ignore ctime changes when checking for modified files")
+	f.BoolVarP(&opts.DryRun, "dry-run", "n", false, "do not upload or write any data, just show what would be done")
+	f.BoolVar(&opts.NoScan, "no-scan", false, "do not run scanner to estimate size of backup")
 	if runtime.GOOS == "windows" {
-		f.BoolVar(&backupOptions.UseFsSnapshot, "use-fs-snapshot", false, "use filesystem snapshot where possible (currently only Windows VSS)")
-		f.BoolVar(&backupOptions.ExcludeCloudFiles, "exclude-cloud-files", false, "excludes online-only cloud files (such as OneDrive Files On-Demand)")
+		f.BoolVar(&opts.UseFsSnapshot, "use-fs-snapshot", false, "use filesystem snapshot where possible (currently only Windows VSS)")
+		f.BoolVar(&opts.ExcludeCloudFiles, "exclude-cloud-files", false, "excludes online-only cloud files (such as OneDrive Files On-Demand)")
 	}
-	f.BoolVar(&backupOptions.SkipIfUnchanged, "skip-if-unchanged", false, "skip snapshot creation if identical to parent snapshot")
+	f.BoolVar(&opts.SkipIfUnchanged, "skip-if-unchanged", false, "skip snapshot creation if identical to parent snapshot")
 
 	// parse read concurrency from env, on error the default value will be used
 	readConcurrency, _ := strconv.ParseUint(os.Getenv("RESTIC_READ_CONCURRENCY"), 10, 32)
-	backupOptions.ReadConcurrency = uint(readConcurrency)
+	opts.ReadConcurrency = uint(readConcurrency)
 
 	// parse host from env, if not exists or empty the default value will be used
 	if host := os.Getenv("RESTIC_HOST"); host != "" {
-		backupOptions.Host = host
+		opts.Host = host
 	}
 }
 
+var backupFSTestHook func(fs fs.FS) fs.FS
+
+// ErrInvalidSourceData is used to report an incomplete backup
+var ErrInvalidSourceData = errors.New("at least one source file could not be read")
+
 // filterExisting returns a slice of all existing items, or an error if no
 // items exist at all.
 func filterExisting(items []string) (result []string, err error) {
diff --git a/cmd/restic/cmd_cache.go b/cmd/restic/cmd_cache.go
index cd970b699..284c94cad 100644
--- a/cmd/restic/cmd_cache.go
+++ b/cmd/restic/cmd_cache.go
@@ -13,12 +13,16 @@ import (
 	"github.com/restic/restic/internal/ui"
 	"github.com/restic/restic/internal/ui/table"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdCache = &cobra.Command{
-	Use:   "cache",
-	Short: "Operate on local cache directories",
-	Long: `
+func newCacheCommand() *cobra.Command {
+	var opts CacheOptions
+
+	cmd := &cobra.Command{
+		Use:   "cache",
+		Short: "Operate on local cache directories",
+		Long: `
 The "cache" command allows listing and cleaning local cache directories.
 
 EXIT STATUS
@@ -27,11 +31,15 @@ EXIT STATUS
 Exit status is 0 if the command was successful.
 Exit status is 1 if there was any error.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(_ *cobra.Command, args []string) error {
-		return runCache(cacheOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(_ *cobra.Command, args []string) error {
+			return runCache(opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // CacheOptions bundles all options for the snapshots command.
@@ -41,15 +49,10 @@ type CacheOptions struct {
 	NoSize  bool
 }
 
-var cacheOptions CacheOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdCache)
-
-	f := cmdCache.Flags()
-	f.BoolVar(&cacheOptions.Cleanup, "cleanup", false, "remove old cache directories")
-	f.UintVar(&cacheOptions.MaxAge, "max-age", 30, "max age in `days` for cache directories to be considered old")
-	f.BoolVar(&cacheOptions.NoSize, "no-size", false, "do not output the size of the cache directories")
+func (opts *CacheOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVar(&opts.Cleanup, "cleanup", false, "remove old cache directories")
+	f.UintVar(&opts.MaxAge, "max-age", 30, "max age in `days` for cache directories to be considered old")
+	f.BoolVar(&opts.NoSize, "no-size", false, "do not output the size of the cache directories")
 }
 
 func runCache(opts CacheOptions, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_cat.go b/cmd/restic/cmd_cat.go
index 6160c54df..983e309dd 100644
--- a/cmd/restic/cmd_cat.go
+++ b/cmd/restic/cmd_cat.go
@@ -14,10 +14,11 @@ import (
 
 var catAllowedCmds = []string{"config", "index", "snapshot", "key", "masterkey", "lock", "pack", "blob", "tree"}
 
-var cmdCat = &cobra.Command{
-	Use:   "cat [flags] [masterkey|config|pack ID|blob ID|snapshot ID|index ID|key ID|lock ID|tree snapshot:subfolder]",
-	Short: "Print internal objects to stdout",
-	Long: `
+func newCatCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "cat [flags] [masterkey|config|pack ID|blob ID|snapshot ID|index ID|key ID|lock ID|tree snapshot:subfolder]",
+		Short: "Print internal objects to stdout",
+		Long: `
 The "cat" command is used to print internal objects to stdout.
 
 EXIT STATUS
@@ -29,16 +30,14 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runCat(cmd.Context(), globalOptions, args)
-	},
-	ValidArgs: catAllowedCmds,
-}
-
-func init() {
-	cmdRoot.AddCommand(cmdCat)
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runCat(cmd.Context(), globalOptions, args)
+		},
+		ValidArgs: catAllowedCmds,
+	}
+	return cmd
 }
 
 func validateCatArgs(args []string) error {
diff --git a/cmd/restic/cmd_check.go b/cmd/restic/cmd_check.go
index 99e933af2..f87303933 100644
--- a/cmd/restic/cmd_check.go
+++ b/cmd/restic/cmd_check.go
@@ -11,6 +11,7 @@ import (
 	"time"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 
 	"github.com/restic/restic/internal/backend/cache"
 	"github.com/restic/restic/internal/checker"
@@ -22,10 +23,12 @@ import (
 	"github.com/restic/restic/internal/ui/termstatus"
 )
 
-var cmdCheck = &cobra.Command{
-	Use:   "check [flags]",
-	Short: "Check the repository for errors",
-	Long: `
+func newCheckCommand() *cobra.Command {
+	var opts CheckOptions
+	cmd := &cobra.Command{
+		Use:   "check [flags]",
+		Short: "Check the repository for errors",
+		Long: `
 The "check" command tests the repository for errors and reports any errors it
 finds. It can also be used to read all data and therefore simulate a restore.
 
@@ -41,23 +44,27 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		summary, err := runCheck(cmd.Context(), checkOptions, globalOptions, args, term)
-		if globalOptions.JSON {
-			if err != nil && summary.NumErrors == 0 {
-				summary.NumErrors = 1
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			summary, err := runCheck(cmd.Context(), opts, globalOptions, args, term)
+			if globalOptions.JSON {
+				if err != nil && summary.NumErrors == 0 {
+					summary.NumErrors = 1
+				}
+				term.Print(ui.ToJSONString(summary))
 			}
-			term.Print(ui.ToJSONString(summary))
-		}
-		return err
-	},
-	PreRunE: func(_ *cobra.Command, _ []string) error {
-		return checkFlags(checkOptions)
-	},
+			return err
+		},
+		PreRunE: func(_ *cobra.Command, _ []string) error {
+			return checkFlags(opts)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // CheckOptions bundles all options for the 'check' command.
@@ -68,14 +75,9 @@ type CheckOptions struct {
 	WithCache      bool
 }
 
-var checkOptions CheckOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdCheck)
-
-	f := cmdCheck.Flags()
-	f.BoolVar(&checkOptions.ReadData, "read-data", false, "read all data blobs")
-	f.StringVar(&checkOptions.ReadDataSubset, "read-data-subset", "", "read a `subset` of data packs, specified as 'n/t' for specific part, or either 'x%' or 'x.y%' or a size in bytes with suffixes k/K, m/M, g/G, t/T for a random subset")
+func (opts *CheckOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVar(&opts.ReadData, "read-data", false, "read all data blobs")
+	f.StringVar(&opts.ReadDataSubset, "read-data-subset", "", "read a `subset` of data packs, specified as 'n/t' for specific part, or either 'x%' or 'x.y%' or a size in bytes with suffixes k/K, m/M, g/G, t/T for a random subset")
 	var ignored bool
 	f.BoolVar(&ignored, "check-unused", false, "find unused blobs")
 	err := f.MarkDeprecated("check-unused", "`--check-unused` is deprecated and will be ignored")
@@ -83,7 +85,7 @@ func init() {
 		// MarkDeprecated only returns an error when the flag is not found
 		panic(err)
 	}
-	f.BoolVar(&checkOptions.WithCache, "with-cache", false, "use existing cache, only read uncached data from repository")
+	f.BoolVar(&opts.WithCache, "with-cache", false, "use existing cache, only read uncached data from repository")
 }
 
 func checkFlags(opts CheckOptions) error {
diff --git a/cmd/restic/cmd_copy.go b/cmd/restic/cmd_copy.go
index 301e0e180..2ad5a464c 100644
--- a/cmd/restic/cmd_copy.go
+++ b/cmd/restic/cmd_copy.go
@@ -11,12 +11,15 @@ import (
 	"golang.org/x/sync/errgroup"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdCopy = &cobra.Command{
-	Use:   "copy [flags] [snapshotID ...]",
-	Short: "Copy snapshots from one repository to another",
-	Long: `
+func newCopyCommand() *cobra.Command {
+	var opts CopyOptions
+	cmd := &cobra.Command{
+		Use:   "copy [flags] [snapshotID ...]",
+		Short: "Copy snapshots from one repository to another",
+		Long: `
 The "copy" command copies one or more snapshots from one repository to another.
 
 NOTE: This process will have to both download (read) and upload (write) the
@@ -40,11 +43,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runCopy(cmd.Context(), copyOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runCopy(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // CopyOptions bundles all options for the copy command.
@@ -53,14 +60,9 @@ type CopyOptions struct {
 	restic.SnapshotFilter
 }
 
-var copyOptions CopyOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdCopy)
-
-	f := cmdCopy.Flags()
-	initSecondaryRepoOptions(f, &copyOptions.secondaryRepoOptions, "destination", "to copy snapshots from")
-	initMultiSnapshotFilter(f, &copyOptions.SnapshotFilter, true)
+func (opts *CopyOptions) AddFlags(f *pflag.FlagSet) {
+	opts.secondaryRepoOptions.AddFlags(f, "destination", "to copy snapshots from")
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
 }
 
 func runCopy(ctx context.Context, opts CopyOptions, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_debug.go b/cmd/restic/cmd_debug.go
index 4ce17f899..63ca86da3 100644
--- a/cmd/restic/cmd_debug.go
+++ b/cmd/restic/cmd_debug.go
@@ -18,6 +18,7 @@ import (
 
 	"github.com/klauspost/compress/zstd"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 	"golang.org/x/sync/errgroup"
 
 	"github.com/restic/restic/internal/crypto"
@@ -28,17 +29,29 @@ import (
 	"github.com/restic/restic/internal/restic"
 )
 
-var cmdDebug = &cobra.Command{
-	Use:               "debug",
-	Short:             "Debug commands",
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
+func registerDebugCommand(cmd *cobra.Command) {
+	cmd.AddCommand(
+		newDebugCommand(),
+	)
 }
 
-var cmdDebugDump = &cobra.Command{
-	Use:   "dump [indexes|snapshots|all|packs]",
-	Short: "Dump data structures",
-	Long: `
+func newDebugCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "debug",
+		Short:             "Debug commands",
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+	}
+	cmd.AddCommand(newDebugDumpCommand())
+	cmd.AddCommand(newDebugExamineCommand())
+	return cmd
+}
+
+func newDebugDumpCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "dump [indexes|snapshots|all|packs]",
+		Short: "Dump data structures",
+		Long: `
 The "dump" command dumps data structures from the repository as JSON objects. It
 is used for debugging purposes only.
 
@@ -51,10 +64,28 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runDebugDump(cmd.Context(), globalOptions, args)
-	},
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDebugDump(cmd.Context(), globalOptions, args)
+		},
+	}
+	return cmd
+}
+
+func newDebugExamineCommand() *cobra.Command {
+	var opts DebugExamineOptions
+
+	cmd := &cobra.Command{
+		Use:               "examine pack-ID...",
+		Short:             "Examine a pack file",
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDebugExamine(cmd.Context(), globalOptions, opts, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 type DebugExamineOptions struct {
@@ -64,16 +95,11 @@ type DebugExamineOptions struct {
 	ReuploadBlobs bool
 }
 
-var debugExamineOpts DebugExamineOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdDebug)
-	cmdDebug.AddCommand(cmdDebugDump)
-	cmdDebug.AddCommand(cmdDebugExamine)
-	cmdDebugExamine.Flags().BoolVar(&debugExamineOpts.ExtractPack, "extract-pack", false, "write blobs to the current directory")
-	cmdDebugExamine.Flags().BoolVar(&debugExamineOpts.ReuploadBlobs, "reupload-blobs", false, "reupload blobs to the repository")
-	cmdDebugExamine.Flags().BoolVar(&debugExamineOpts.TryRepair, "try-repair", false, "try to repair broken blobs with single bit flips")
-	cmdDebugExamine.Flags().BoolVar(&debugExamineOpts.RepairByte, "repair-byte", false, "try to repair broken blobs by trying bytes")
+func (opts *DebugExamineOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVar(&opts.ExtractPack, "extract-pack", false, "write blobs to the current directory")
+	f.BoolVar(&opts.ReuploadBlobs, "reupload-blobs", false, "reupload blobs to the repository")
+	f.BoolVar(&opts.TryRepair, "try-repair", false, "try to repair broken blobs with single bit flips")
+	f.BoolVar(&opts.RepairByte, "repair-byte", false, "try to repair broken blobs by trying bytes")
 }
 
 func prettyPrintJSON(wr io.Writer, item interface{}) error {
@@ -92,7 +118,9 @@ func debugPrintSnapshots(ctx context.Context, repo *repository.Repository, wr io
 			return err
 		}
 
-		fmt.Fprintf(wr, "snapshot_id: %v\n", id)
+		if _, err := fmt.Fprintf(wr, "snapshot_id: %v\n", id); err != nil {
+			return err
+		}
 
 		return prettyPrintJSON(wr, snapshot)
 	})
@@ -192,16 +220,7 @@ func runDebugDump(ctx context.Context, gopts GlobalOptions, args []string) error
 	}
 }
 
-var cmdDebugExamine = &cobra.Command{
-	Use:               "examine pack-ID...",
-	Short:             "Examine a pack file",
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runDebugExamine(cmd.Context(), globalOptions, debugExamineOpts, args)
-	},
-}
-
-func tryRepairWithBitflip(ctx context.Context, key *crypto.Key, input []byte, bytewise bool) []byte {
+func tryRepairWithBitflip(key *crypto.Key, input []byte, bytewise bool) []byte {
 	if bytewise {
 		Printf("        trying to repair blob by finding a broken byte\n")
 	} else {
@@ -300,7 +319,7 @@ func tryRepairWithBitflip(ctx context.Context, key *crypto.Key, input []byte, by
 	return fixed
 }
 
-func decryptUnsigned(ctx context.Context, k *crypto.Key, buf []byte) []byte {
+func decryptUnsigned(k *crypto.Key, buf []byte) []byte {
 	// strip signature at the end
 	l := len(buf)
 	nonce, ct := buf[:16], buf[16:l-16]
@@ -351,13 +370,13 @@ func loadBlobs(ctx context.Context, opts DebugExamineOptions, repo restic.Reposi
 			if err != nil {
 				Warnf("error decrypting blob: %v\n", err)
 				if opts.TryRepair || opts.RepairByte {
-					plaintext = tryRepairWithBitflip(ctx, key, buf, opts.RepairByte)
+					plaintext = tryRepairWithBitflip(key, buf, opts.RepairByte)
 				}
 				if plaintext != nil {
 					outputPrefix = "repaired "
 					filePrefix = "repaired-"
 				} else {
-					plaintext = decryptUnsigned(ctx, key, buf)
+					plaintext = decryptUnsigned(key, buf)
 					err = storePlainBlob(blob.ID, "damaged-", plaintext)
 					if err != nil {
 						return err
diff --git a/cmd/restic/cmd_debug_disabled.go b/cmd/restic/cmd_debug_disabled.go
new file mode 100644
index 000000000..34d06a467
--- /dev/null
+++ b/cmd/restic/cmd_debug_disabled.go
@@ -0,0 +1,9 @@
+//go:build !debug
+
+package main
+
+import "github.com/spf13/cobra"
+
+func registerDebugCommand(_ *cobra.Command) {
+	// No commands to register in non-debug mode
+}
diff --git a/cmd/restic/cmd_diff.go b/cmd/restic/cmd_diff.go
index d1067b5ec..35fe97fbb 100644
--- a/cmd/restic/cmd_diff.go
+++ b/cmd/restic/cmd_diff.go
@@ -12,12 +12,16 @@ import (
 	"github.com/restic/restic/internal/restic"
 	"github.com/restic/restic/internal/ui"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdDiff = &cobra.Command{
-	Use:   "diff [flags] snapshotID snapshotID",
-	Short: "Show differences between two snapshots",
-	Long: `
+func newDiffCommand() *cobra.Command {
+	var opts DiffOptions
+
+	cmd := &cobra.Command{
+		Use:   "diff [flags] snapshotID snapshotID",
+		Short: "Show differences between two snapshots",
+		Long: `
 The "diff" command shows differences from the first to the second snapshot. The
 first characters in each line display what has happened to a particular file or
 directory:
@@ -45,11 +49,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runDiff(cmd.Context(), diffOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDiff(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // DiffOptions collects all options for the diff command.
@@ -57,13 +65,8 @@ type DiffOptions struct {
 	ShowMetadata bool
 }
 
-var diffOptions DiffOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdDiff)
-
-	f := cmdDiff.Flags()
-	f.BoolVar(&diffOptions.ShowMetadata, "metadata", false, "print changes in metadata")
+func (opts *DiffOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVar(&opts.ShowMetadata, "metadata", false, "print changes in metadata")
 }
 
 func loadSnapshot(ctx context.Context, be restic.Lister, repo restic.LoaderUnpacked, desc string) (*restic.Snapshot, string, error) {
diff --git a/cmd/restic/cmd_dump.go b/cmd/restic/cmd_dump.go
index 6b7f8d012..978b64616 100644
--- a/cmd/restic/cmd_dump.go
+++ b/cmd/restic/cmd_dump.go
@@ -13,12 +13,15 @@ import (
 	"github.com/restic/restic/internal/restic"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdDump = &cobra.Command{
-	Use:   "dump [flags] snapshotID file",
-	Short: "Print a backed-up file to stdout",
-	Long: `
+func newDumpCommand() *cobra.Command {
+	var opts DumpOptions
+	cmd := &cobra.Command{
+		Use:   "dump [flags] snapshotID file",
+		Short: "Print a backed-up file to stdout",
+		Long: `
 The "dump" command extracts files from a snapshot from the repository. If a
 single file is selected, it prints its contents to stdout. Folders are output
 as a tar (default) or zip file containing the contents of the specified folder.
@@ -40,11 +43,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runDump(cmd.Context(), dumpOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDump(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // DumpOptions collects all options for the dump command.
@@ -54,15 +61,10 @@ type DumpOptions struct {
 	Target  string
 }
 
-var dumpOptions DumpOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdDump)
-
-	flags := cmdDump.Flags()
-	initSingleSnapshotFilter(flags, &dumpOptions.SnapshotFilter)
-	flags.StringVarP(&dumpOptions.Archive, "archive", "a", "tar", "set archive `format` as \"tar\" or \"zip\"")
-	flags.StringVarP(&dumpOptions.Target, "target", "t", "", "write the output to target `path`")
+func (opts *DumpOptions) AddFlags(f *pflag.FlagSet) {
+	initSingleSnapshotFilter(f, &opts.SnapshotFilter)
+	f.StringVarP(&opts.Archive, "archive", "a", "tar", "set archive `format` as \"tar\" or \"zip\"")
+	f.StringVarP(&opts.Target, "target", "t", "", "write the output to target `path`")
 }
 
 func splitPath(p string) []string {
diff --git a/cmd/restic/cmd_features.go b/cmd/restic/cmd_features.go
index a2f04be31..7770e6a9f 100644
--- a/cmd/restic/cmd_features.go
+++ b/cmd/restic/cmd_features.go
@@ -10,10 +10,11 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var featuresCmd = &cobra.Command{
-	Use:   "features",
-	Short: "Print list of feature flags",
-	Long: `
+func newFeaturesCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "features",
+		Short: "Print list of feature flags",
+		Long: `
 The "features" command prints a list of supported feature flags.
 
 To pass feature flags to restic, set the RESTIC_FEATURES environment variable
@@ -31,29 +32,28 @@ EXIT STATUS
 Exit status is 0 if the command was successful.
 Exit status is 1 if there was any error.
 `,
-	GroupID:           cmdGroupAdvanced,
-	DisableAutoGenTag: true,
-	RunE: func(_ *cobra.Command, args []string) error {
-		if len(args) != 0 {
-			return errors.Fatal("the feature command expects no arguments")
-		}
-
-		fmt.Printf("All Feature Flags:\n")
-		flags := feature.Flag.List()
-
-		tab := table.New()
-		tab.AddColumn("Name", "{{ .Name }}")
-		tab.AddColumn("Type", "{{ .Type }}")
-		tab.AddColumn("Default", "{{ .Default }}")
-		tab.AddColumn("Description", "{{ .Description }}")
-
-		for _, flag := range flags {
-			tab.AddRow(flag)
-		}
-		return tab.Write(globalOptions.stdout)
-	},
-}
-
-func init() {
-	cmdRoot.AddCommand(featuresCmd)
+		GroupID:           cmdGroupAdvanced,
+		DisableAutoGenTag: true,
+		RunE: func(_ *cobra.Command, args []string) error {
+			if len(args) != 0 {
+				return errors.Fatal("the feature command expects no arguments")
+			}
+
+			fmt.Printf("All Feature Flags:\n")
+			flags := feature.Flag.List()
+
+			tab := table.New()
+			tab.AddColumn("Name", "{{ .Name }}")
+			tab.AddColumn("Type", "{{ .Type }}")
+			tab.AddColumn("Default", "{{ .Default }}")
+			tab.AddColumn("Description", "{{ .Description }}")
+
+			for _, flag := range flags {
+				tab.AddRow(flag)
+			}
+			return tab.Write(globalOptions.stdout)
+		},
+	}
+
+	return cmd
 }
diff --git a/cmd/restic/cmd_find.go b/cmd/restic/cmd_find.go
index 2fcef5741..d2fa3619a 100644
--- a/cmd/restic/cmd_find.go
+++ b/cmd/restic/cmd_find.go
@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
@@ -16,16 +17,19 @@ import (
 	"github.com/restic/restic/internal/walker"
 )
 
-var cmdFind = &cobra.Command{
-	Use:   "find [flags] PATTERN...",
-	Short: "Find a file, a directory or restic IDs",
-	Long: `
+func newFindCommand() *cobra.Command {
+	var opts FindOptions
+
+	cmd := &cobra.Command{
+		Use:   "find [flags] PATTERN...",
+		Short: "Find a file, a directory or restic IDs",
+		Long: `
 The "find" command searches for files or directories in snapshots stored in the
 repo.
 It can also be used to search for restic blobs or trees for troubleshooting.
 The default sort option for the snapshots is youngest to oldest. To sort the
 output from oldest to youngest specify --reverse.`,
-	Example: `restic find config.json
+		Example: `restic find config.json
 restic find --json "*.yml" "*.json"
 restic find --json --blob 420f620f b46ebe8a ddd38656
 restic find --show-pack-id --blob 420f620f
@@ -41,11 +45,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runFind(cmd.Context(), findOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runFind(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // FindOptions bundles all options for the find command.
@@ -62,25 +70,20 @@ type FindOptions struct {
 	restic.SnapshotFilter
 }
 
-var findOptions FindOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdFind)
-
-	f := cmdFind.Flags()
-	f.StringVarP(&findOptions.Oldest, "oldest", "O", "", "oldest modification date/time")
-	f.StringVarP(&findOptions.Newest, "newest", "N", "", "newest modification date/time")
-	f.StringArrayVarP(&findOptions.Snapshots, "snapshot", "s", nil, "snapshot `id` to search in (can be given multiple times)")
-	f.BoolVar(&findOptions.BlobID, "blob", false, "pattern is a blob-ID")
-	f.BoolVar(&findOptions.TreeID, "tree", false, "pattern is a tree-ID")
-	f.BoolVar(&findOptions.PackID, "pack", false, "pattern is a pack-ID")
-	f.BoolVar(&findOptions.ShowPackID, "show-pack-id", false, "display the pack-ID the blobs belong to (with --blob or --tree)")
-	f.BoolVarP(&findOptions.CaseInsensitive, "ignore-case", "i", false, "ignore case for pattern")
-	f.BoolVarP(&findOptions.Reverse, "reverse", "R", false, "reverse sort order oldest to newest")
-	f.BoolVarP(&findOptions.ListLong, "long", "l", false, "use a long listing format showing size and mode")
-	f.BoolVar(&findOptions.HumanReadable, "human-readable", false, "print sizes in human readable format")
-
-	initMultiSnapshotFilter(f, &findOptions.SnapshotFilter, true)
+func (opts *FindOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVarP(&opts.Oldest, "oldest", "O", "", "oldest modification date/time")
+	f.StringVarP(&opts.Newest, "newest", "N", "", "newest modification date/time")
+	f.StringArrayVarP(&opts.Snapshots, "snapshot", "s", nil, "snapshot `id` to search in (can be given multiple times)")
+	f.BoolVar(&opts.BlobID, "blob", false, "pattern is a blob-ID")
+	f.BoolVar(&opts.TreeID, "tree", false, "pattern is a tree-ID")
+	f.BoolVar(&opts.PackID, "pack", false, "pattern is a pack-ID")
+	f.BoolVar(&opts.ShowPackID, "show-pack-id", false, "display the pack-ID the blobs belong to (with --blob or --tree)")
+	f.BoolVarP(&opts.CaseInsensitive, "ignore-case", "i", false, "ignore case for pattern")
+	f.BoolVarP(&opts.Reverse, "reverse", "R", false, "reverse sort order oldest to newest")
+	f.BoolVarP(&opts.ListLong, "long", "l", false, "use a long listing format showing size and mode")
+	f.BoolVar(&opts.HumanReadable, "human-readable", false, "print sizes in human readable format")
+
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
 }
 
 type findPattern struct {
diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go
index f9ae85cd1..2bfacc4de 100644
--- a/cmd/restic/cmd_forget.go
+++ b/cmd/restic/cmd_forget.go
@@ -11,12 +11,17 @@ import (
 	"github.com/restic/restic/internal/restic"
 	"github.com/restic/restic/internal/ui/termstatus"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdForget = &cobra.Command{
-	Use:   "forget [flags] [snapshot ID] [...]",
-	Short: "Remove snapshots from the repository",
-	Long: `
+func newForgetCommand() *cobra.Command {
+	var opts ForgetOptions
+	var pruneOpts PruneOptions
+
+	cmd := &cobra.Command{
+		Use:   "forget [flags] [snapshot ID] [...]",
+		Short: "Remove snapshots from the repository",
+		Long: `
 The "forget" command removes snapshots according to a policy. All snapshots are
 first divided into groups according to "--group-by", and after that the policy
 specified by the "--keep-*" options is applied to each group individually.
@@ -40,13 +45,18 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runForget(cmd.Context(), forgetOptions, forgetPruneOptions, globalOptions, term, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runForget(cmd.Context(), opts, pruneOpts, globalOptions, term, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	pruneOpts.AddLimitedFlags(cmd.Flags())
+	return cmd
 }
 
 type ForgetPolicyCount int
@@ -111,44 +121,37 @@ type ForgetOptions struct {
 	Prune   bool
 }
 
-var forgetOptions ForgetOptions
-var forgetPruneOptions PruneOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdForget)
-
-	f := cmdForget.Flags()
-	f.VarP(&forgetOptions.Last, "keep-last", "l", "keep the last `n` snapshots (use 'unlimited' to keep all snapshots)")
-	f.VarP(&forgetOptions.Hourly, "keep-hourly", "H", "keep the last `n` hourly snapshots (use 'unlimited' to keep all hourly snapshots)")
-	f.VarP(&forgetOptions.Daily, "keep-daily", "d", "keep the last `n` daily snapshots (use 'unlimited' to keep all daily snapshots)")
-	f.VarP(&forgetOptions.Weekly, "keep-weekly", "w", "keep the last `n` weekly snapshots (use 'unlimited' to keep all weekly snapshots)")
-	f.VarP(&forgetOptions.Monthly, "keep-monthly", "m", "keep the last `n` monthly snapshots (use 'unlimited' to keep all monthly snapshots)")
-	f.VarP(&forgetOptions.Yearly, "keep-yearly", "y", "keep the last `n` yearly snapshots (use 'unlimited' to keep all yearly snapshots)")
-	f.VarP(&forgetOptions.Within, "keep-within", "", "keep snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
-	f.VarP(&forgetOptions.WithinHourly, "keep-within-hourly", "", "keep hourly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
-	f.VarP(&forgetOptions.WithinDaily, "keep-within-daily", "", "keep daily snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
-	f.VarP(&forgetOptions.WithinWeekly, "keep-within-weekly", "", "keep weekly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
-	f.VarP(&forgetOptions.WithinMonthly, "keep-within-monthly", "", "keep monthly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
-	f.VarP(&forgetOptions.WithinYearly, "keep-within-yearly", "", "keep yearly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
-	f.Var(&forgetOptions.KeepTags, "keep-tag", "keep snapshots with this `taglist` (can be specified multiple times)")
-	f.BoolVar(&forgetOptions.UnsafeAllowRemoveAll, "unsafe-allow-remove-all", false, "allow deleting all snapshots of a snapshot group")
-
-	initMultiSnapshotFilter(f, &forgetOptions.SnapshotFilter, false)
-	f.StringArrayVar(&forgetOptions.Hosts, "hostname", nil, "only consider snapshots with the given `hostname` (can be specified multiple times)")
+func (opts *ForgetOptions) AddFlags(f *pflag.FlagSet) {
+	f.VarP(&opts.Last, "keep-last", "l", "keep the last `n` snapshots (use 'unlimited' to keep all snapshots)")
+	f.VarP(&opts.Hourly, "keep-hourly", "H", "keep the last `n` hourly snapshots (use 'unlimited' to keep all hourly snapshots)")
+	f.VarP(&opts.Daily, "keep-daily", "d", "keep the last `n` daily snapshots (use 'unlimited' to keep all daily snapshots)")
+	f.VarP(&opts.Weekly, "keep-weekly", "w", "keep the last `n` weekly snapshots (use 'unlimited' to keep all weekly snapshots)")
+	f.VarP(&opts.Monthly, "keep-monthly", "m", "keep the last `n` monthly snapshots (use 'unlimited' to keep all monthly snapshots)")
+	f.VarP(&opts.Yearly, "keep-yearly", "y", "keep the last `n` yearly snapshots (use 'unlimited' to keep all yearly snapshots)")
+	f.VarP(&opts.Within, "keep-within", "", "keep snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
+	f.VarP(&opts.WithinHourly, "keep-within-hourly", "", "keep hourly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
+	f.VarP(&opts.WithinDaily, "keep-within-daily", "", "keep daily snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
+	f.VarP(&opts.WithinWeekly, "keep-within-weekly", "", "keep weekly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
+	f.VarP(&opts.WithinMonthly, "keep-within-monthly", "", "keep monthly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
+	f.VarP(&opts.WithinYearly, "keep-within-yearly", "", "keep yearly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot")
+	f.Var(&opts.KeepTags, "keep-tag", "keep snapshots with this `taglist` (can be specified multiple times)")
+	f.BoolVar(&opts.UnsafeAllowRemoveAll, "unsafe-allow-remove-all", false, "allow deleting all snapshots of a snapshot group")
+
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, false)
+	f.StringArrayVar(&opts.Hosts, "hostname", nil, "only consider snapshots with the given `hostname` (can be specified multiple times)")
 	err := f.MarkDeprecated("hostname", "use --host")
 	if err != nil {
 		// MarkDeprecated only returns an error when the flag is not found
 		panic(err)
 	}
 
-	f.BoolVarP(&forgetOptions.Compact, "compact", "c", false, "use compact output format")
-	forgetOptions.GroupBy = restic.SnapshotGroupByOptions{Host: true, Path: true}
-	f.VarP(&forgetOptions.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma (disable grouping with '')")
-	f.BoolVarP(&forgetOptions.DryRun, "dry-run", "n", false, "do not delete anything, just print what would be done")
-	f.BoolVar(&forgetOptions.Prune, "prune", false, "automatically run the 'prune' command if snapshots have been removed")
+	f.BoolVarP(&opts.Compact, "compact", "c", false, "use compact output format")
+	opts.GroupBy = restic.SnapshotGroupByOptions{Host: true, Path: true}
+	f.VarP(&opts.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma (disable grouping with '')")
+	f.BoolVarP(&opts.DryRun, "dry-run", "n", false, "do not delete anything, just print what would be done")
+	f.BoolVar(&opts.Prune, "prune", false, "automatically run the 'prune' command if snapshots have been removed")
 
 	f.SortFlags = false
-	addPruneOptions(cmdForget, &forgetPruneOptions)
 }
 
 func verifyForgetOptions(opts *ForgetOptions) error {
diff --git a/cmd/restic/cmd_generate.go b/cmd/restic/cmd_generate.go
index 66b3fa7c5..5ced5fe01 100644
--- a/cmd/restic/cmd_generate.go
+++ b/cmd/restic/cmd_generate.go
@@ -8,12 +8,16 @@ import (
 	"github.com/restic/restic/internal/errors"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra/doc"
+	"github.com/spf13/pflag"
 )
 
-var cmdGenerate = &cobra.Command{
-	Use:   "generate [flags]",
-	Short: "Generate manual pages and auto-completion files (bash, fish, zsh, powershell)",
-	Long: `
+func newGenerateCommand() *cobra.Command {
+	var opts generateOptions
+
+	cmd := &cobra.Command{
+		Use:   "generate [flags]",
+		Short: "Generate manual pages and auto-completion files (bash, fish, zsh, powershell)",
+		Long: `
 The "generate" command writes automatically generated files (like the man pages
 and the auto-completion files for bash, fish and zsh).
 
@@ -23,10 +27,13 @@ EXIT STATUS
 Exit status is 0 if the command was successful.
 Exit status is 1 if there was any error.
 `,
-	DisableAutoGenTag: true,
-	RunE: func(_ *cobra.Command, args []string) error {
-		return runGenerate(genOpts, args)
-	},
+		DisableAutoGenTag: true,
+		RunE: func(_ *cobra.Command, args []string) error {
+			return runGenerate(opts, args)
+		},
+	}
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 type generateOptions struct {
@@ -37,19 +44,15 @@ type generateOptions struct {
 	PowerShellCompletionFile string
 }
 
-var genOpts generateOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdGenerate)
-	fs := cmdGenerate.Flags()
-	fs.StringVar(&genOpts.ManDir, "man", "", "write man pages to `directory`")
-	fs.StringVar(&genOpts.BashCompletionFile, "bash-completion", "", "write bash completion `file` (`-` for stdout)")
-	fs.StringVar(&genOpts.FishCompletionFile, "fish-completion", "", "write fish completion `file` (`-` for stdout)")
-	fs.StringVar(&genOpts.ZSHCompletionFile, "zsh-completion", "", "write zsh completion `file` (`-` for stdout)")
-	fs.StringVar(&genOpts.PowerShellCompletionFile, "powershell-completion", "", "write powershell completion `file` (`-` for stdout)")
+func (opts *generateOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVar(&opts.ManDir, "man", "", "write man pages to `directory`")
+	f.StringVar(&opts.BashCompletionFile, "bash-completion", "", "write bash completion `file` (`-` for stdout)")
+	f.StringVar(&opts.FishCompletionFile, "fish-completion", "", "write fish completion `file` (`-` for stdout)")
+	f.StringVar(&opts.ZSHCompletionFile, "zsh-completion", "", "write zsh completion `file` (`-` for stdout)")
+	f.StringVar(&opts.PowerShellCompletionFile, "powershell-completion", "", "write powershell completion `file` (`-` for stdout)")
 }
 
-func writeManpages(dir string) error {
+func writeManpages(root *cobra.Command, dir string) error {
 	// use a fixed date for the man pages so that generating them is deterministic
 	date, err := time.Parse("Jan 2006", "Jan 2017")
 	if err != nil {
@@ -64,7 +67,7 @@ func writeManpages(dir string) error {
 	}
 
 	Verbosef("writing man pages to directory %v\n", dir)
-	return doc.GenManTree(cmdRoot, header, dir)
+	return doc.GenManTree(root, header, dir)
 }
 
 func writeCompletion(filename string, shell string, generate func(w io.Writer) error) (err error) {
@@ -112,8 +115,10 @@ func runGenerate(opts generateOptions, args []string) error {
 		return errors.Fatal("the generate command expects no arguments, only options - please see `restic help generate` for usage and flags")
 	}
 
+	cmdRoot := newRootCommand()
+
 	if opts.ManDir != "" {
-		err := writeManpages(opts.ManDir)
+		err := writeManpages(cmdRoot, opts.ManDir)
 		if err != nil {
 			return err
 		}
diff --git a/cmd/restic/cmd_init.go b/cmd/restic/cmd_init.go
index 2a2aae1dc..d66163af1 100644
--- a/cmd/restic/cmd_init.go
+++ b/cmd/restic/cmd_init.go
@@ -12,12 +12,16 @@ import (
 	"github.com/restic/restic/internal/restic"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdInit = &cobra.Command{
-	Use:   "init",
-	Short: "Initialize a new repository",
-	Long: `
+func newInitCommand() *cobra.Command {
+	var opts InitOptions
+
+	cmd := &cobra.Command{
+		Use:   "init",
+		Short: "Initialize a new repository",
+		Long: `
 The "init" command initializes a new repository.
 
 EXIT STATUS
@@ -26,11 +30,14 @@ EXIT STATUS
 Exit status is 0 if the command was successful.
 Exit status is 1 if there was any error.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runInit(cmd.Context(), initOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runInit(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // InitOptions bundles all options for the init command.
@@ -40,15 +47,10 @@ type InitOptions struct {
 	RepositoryVersion     string
 }
 
-var initOptions InitOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdInit)
-
-	f := cmdInit.Flags()
-	initSecondaryRepoOptions(f, &initOptions.secondaryRepoOptions, "secondary", "to copy chunker parameters from")
-	f.BoolVar(&initOptions.CopyChunkerParameters, "copy-chunker-params", false, "copy chunker parameters from the secondary repository (useful with the copy command)")
-	f.StringVar(&initOptions.RepositoryVersion, "repository-version", "stable", "repository format version to use, allowed values are a format version, 'latest' and 'stable'")
+func (opts *InitOptions) AddFlags(f *pflag.FlagSet) {
+	opts.secondaryRepoOptions.AddFlags(f, "secondary", "to copy chunker parameters from")
+	f.BoolVar(&opts.CopyChunkerParameters, "copy-chunker-params", false, "copy chunker parameters from the secondary repository (useful with the copy command)")
+	f.StringVar(&opts.RepositoryVersion, "repository-version", "stable", "repository format version to use, allowed values are a format version, 'latest' and 'stable'")
 }
 
 func runInit(ctx context.Context, opts InitOptions, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_key.go b/cmd/restic/cmd_key.go
index a94caa0d8..29d38bdce 100644
--- a/cmd/restic/cmd_key.go
+++ b/cmd/restic/cmd_key.go
@@ -4,17 +4,23 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var cmdKey = &cobra.Command{
-	Use:   "key",
-	Short: "Manage keys (passwords)",
-	Long: `
+func newKeyCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "key",
+		Short: "Manage keys (passwords)",
+		Long: `
 The "key" command allows you to set multiple access keys or passwords
 per repository.
 	`,
-	DisableAutoGenTag: true,
-	GroupID:           cmdGroupDefault,
-}
+		DisableAutoGenTag: true,
+		GroupID:           cmdGroupDefault,
+	}
 
-func init() {
-	cmdRoot.AddCommand(cmdKey)
+	cmd.AddCommand(
+		newKeyAddCommand(),
+		newKeyListCommand(),
+		newKeyPasswdCommand(),
+		newKeyRemoveCommand(),
+	)
+	return cmd
 }
diff --git a/cmd/restic/cmd_key_add.go b/cmd/restic/cmd_key_add.go
index 2737410a0..a7670a842 100644
--- a/cmd/restic/cmd_key_add.go
+++ b/cmd/restic/cmd_key_add.go
@@ -10,10 +10,13 @@ import (
 	"github.com/spf13/pflag"
 )
 
-var cmdKeyAdd = &cobra.Command{
-	Use:   "add",
-	Short: "Add a new key (password) to the repository; returns the new key ID",
-	Long: `
+func newKeyAddCommand() *cobra.Command {
+	var opts KeyAddOptions
+
+	cmd := &cobra.Command{
+		Use:   "add",
+		Short: "Add a new key (password) to the repository; returns the new key ID",
+		Long: `
 The "add" sub-command creates a new key and validates the key. Returns the new key ID.
 
 EXIT STATUS
@@ -25,7 +28,14 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 	`,
-	DisableAutoGenTag: true,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runKeyAdd(cmd.Context(), globalOptions, opts, args)
+		},
+	}
+
+	opts.Add(cmd.Flags())
+	return cmd
 }
 
 type KeyAddOptions struct {
@@ -42,16 +52,6 @@ func (opts *KeyAddOptions) Add(flags *pflag.FlagSet) {
 	flags.StringVarP(&opts.Hostname, "host", "", "", "the hostname for new key")
 }
 
-func init() {
-	cmdKey.AddCommand(cmdKeyAdd)
-
-	var keyAddOpts KeyAddOptions
-	keyAddOpts.Add(cmdKeyAdd.Flags())
-	cmdKeyAdd.RunE = func(cmd *cobra.Command, args []string) error {
-		return runKeyAdd(cmd.Context(), globalOptions, keyAddOpts, args)
-	}
-}
-
 func runKeyAdd(ctx context.Context, gopts GlobalOptions, opts KeyAddOptions, args []string) error {
 	if len(args) > 0 {
 		return fmt.Errorf("the key add command expects no arguments, only options - please see `restic help key add` for usage and flags")
diff --git a/cmd/restic/cmd_key_list.go b/cmd/restic/cmd_key_list.go
index 1c70cce8a..6a0509f0f 100644
--- a/cmd/restic/cmd_key_list.go
+++ b/cmd/restic/cmd_key_list.go
@@ -12,10 +12,11 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var cmdKeyList = &cobra.Command{
-	Use:   "list",
-	Short: "List keys (passwords)",
-	Long: `
+func newKeyListCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List keys (passwords)",
+		Long: `
 The "list" sub-command lists all the keys (passwords) associated with the repository.
 Returns the key ID, username, hostname, created time and if it's the current key being
 used to access the repository.
@@ -29,14 +30,12 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 	`,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runKeyList(cmd.Context(), globalOptions, args)
-	},
-}
-
-func init() {
-	cmdKey.AddCommand(cmdKeyList)
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runKeyList(cmd.Context(), globalOptions, args)
+		},
+	}
+	return cmd
 }
 
 func runKeyList(ctx context.Context, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_key_passwd.go b/cmd/restic/cmd_key_passwd.go
index 9bb141749..378325216 100644
--- a/cmd/restic/cmd_key_passwd.go
+++ b/cmd/restic/cmd_key_passwd.go
@@ -7,12 +7,16 @@ import (
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/repository"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdKeyPasswd = &cobra.Command{
-	Use:   "passwd",
-	Short: "Change key (password); creates a new key ID and removes the old key ID, returns new key ID",
-	Long: `
+func newKeyPasswdCommand() *cobra.Command {
+	var opts KeyPasswdOptions
+
+	cmd := &cobra.Command{
+		Use:   "passwd",
+		Short: "Change key (password); creates a new key ID and removes the old key ID, returns new key ID",
+		Long: `
 The "passwd" sub-command creates a new key, validates the key and remove the old key ID.
 Returns the new key ID. 
 
@@ -25,21 +29,22 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 	`,
-	DisableAutoGenTag: true,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runKeyPasswd(cmd.Context(), globalOptions, opts, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 type KeyPasswdOptions struct {
 	KeyAddOptions
 }
 
-func init() {
-	cmdKey.AddCommand(cmdKeyPasswd)
-
-	var keyPasswdOpts KeyPasswdOptions
-	keyPasswdOpts.KeyAddOptions.Add(cmdKeyPasswd.Flags())
-	cmdKeyPasswd.RunE = func(cmd *cobra.Command, args []string) error {
-		return runKeyPasswd(cmd.Context(), globalOptions, keyPasswdOpts, args)
-	}
+func (opts *KeyPasswdOptions) AddFlags(flags *pflag.FlagSet) {
+	opts.KeyAddOptions.Add(flags)
 }
 
 func runKeyPasswd(ctx context.Context, gopts GlobalOptions, opts KeyPasswdOptions, args []string) error {
diff --git a/cmd/restic/cmd_key_remove.go b/cmd/restic/cmd_key_remove.go
index 3cb2e0bd7..f6713e4c2 100644
--- a/cmd/restic/cmd_key_remove.go
+++ b/cmd/restic/cmd_key_remove.go
@@ -10,10 +10,11 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var cmdKeyRemove = &cobra.Command{
-	Use:   "remove [ID]",
-	Short: "Remove key ID (password) from the repository.",
-	Long: `
+func newKeyRemoveCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "remove [ID]",
+		Short: "Remove key ID (password) from the repository.",
+		Long: `
 The "remove" sub-command removes the selected key ID. The "remove" command does not allow
 removing the current key being used to access the repository. 
 
@@ -26,14 +27,12 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 	`,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runKeyRemove(cmd.Context(), globalOptions, args)
-	},
-}
-
-func init() {
-	cmdKey.AddCommand(cmdKeyRemove)
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runKeyRemove(cmd.Context(), globalOptions, args)
+		},
+	}
+	return cmd
 }
 
 func runKeyRemove(ctx context.Context, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_list.go b/cmd/restic/cmd_list.go
index d66cddc4f..cf0cec414 100644
--- a/cmd/restic/cmd_list.go
+++ b/cmd/restic/cmd_list.go
@@ -11,13 +11,14 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var listAllowedArgs = []string{"blobs", "packs", "index", "snapshots", "keys", "locks"}
-var listAllowedArgsUseString = strings.Join(listAllowedArgs, "|")
+func newListCommand() *cobra.Command {
+	var listAllowedArgs = []string{"blobs", "packs", "index", "snapshots", "keys", "locks"}
+	var listAllowedArgsUseString = strings.Join(listAllowedArgs, "|")
 
-var cmdList = &cobra.Command{
-	Use:   "list [flags] [" + listAllowedArgsUseString + "]",
-	Short: "List objects in the repository",
-	Long: `
+	cmd := &cobra.Command{
+		Use:   "list [flags] [" + listAllowedArgsUseString + "]",
+		Short: "List objects in the repository",
+		Long: `
 The "list" command allows listing objects in the repository based on type.
 
 EXIT STATUS
@@ -29,17 +30,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	GroupID:           cmdGroupDefault,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runList(cmd.Context(), globalOptions, args)
-	},
-	ValidArgs: listAllowedArgs,
-	Args:      cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
-}
-
-func init() {
-	cmdRoot.AddCommand(cmdList)
+		DisableAutoGenTag: true,
+		GroupID:           cmdGroupDefault,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runList(cmd.Context(), globalOptions, args)
+		},
+		ValidArgs: listAllowedArgs,
+		Args:      cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
+	}
+	return cmd
 }
 
 func runList(ctx context.Context, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_ls.go b/cmd/restic/cmd_ls.go
index 373a31a40..7a24d4c9b 100644
--- a/cmd/restic/cmd_ls.go
+++ b/cmd/restic/cmd_ls.go
@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/fs"
@@ -20,10 +21,13 @@ import (
 	"github.com/restic/restic/internal/walker"
 )
 
-var cmdLs = &cobra.Command{
-	Use:   "ls [flags] snapshotID [dir...]",
-	Short: "List files in a snapshot",
-	Long: `
+func newLsCommand() *cobra.Command {
+	var opts LsOptions
+
+	cmd := &cobra.Command{
+		Use:   "ls [flags] snapshotID [dir...]",
+		Short: "List files in a snapshot",
+		Long: `
 The "ls" command lists files and directories in a snapshot.
 
 The special snapshot ID "latest" can be used to list files and
@@ -52,11 +56,14 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	GroupID:           cmdGroupDefault,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runLs(cmd.Context(), lsOptions, globalOptions, args)
-	},
+		DisableAutoGenTag: true,
+		GroupID:           cmdGroupDefault,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runLs(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // LsOptions collects all options for the ls command.
@@ -70,19 +77,14 @@ type LsOptions struct {
 	Reverse       bool
 }
 
-var lsOptions LsOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdLs)
-
-	flags := cmdLs.Flags()
-	initSingleSnapshotFilter(flags, &lsOptions.SnapshotFilter)
-	flags.BoolVarP(&lsOptions.ListLong, "long", "l", false, "use a long listing format showing size and mode")
-	flags.BoolVar(&lsOptions.Recursive, "recursive", false, "include files in subfolders of the listed directories")
-	flags.BoolVar(&lsOptions.HumanReadable, "human-readable", false, "print sizes in human readable format")
-	flags.BoolVar(&lsOptions.Ncdu, "ncdu", false, "output NCDU export format (pipe into 'ncdu -f -')")
-	flags.VarP(&lsOptions.Sort, "sort", "s", "sort output by (name|size|time=mtime|atime|ctime|extension)")
-	flags.BoolVar(&lsOptions.Reverse, "reverse", false, "reverse sorted output")
+func (opts *LsOptions) AddFlags(f *pflag.FlagSet) {
+	initSingleSnapshotFilter(f, &opts.SnapshotFilter)
+	f.BoolVarP(&opts.ListLong, "long", "l", false, "use a long listing format showing size and mode")
+	f.BoolVar(&opts.Recursive, "recursive", false, "include files in subfolders of the listed directories")
+	f.BoolVar(&opts.HumanReadable, "human-readable", false, "print sizes in human readable format")
+	f.BoolVar(&opts.Ncdu, "ncdu", false, "output NCDU export format (pipe into 'ncdu -f -')")
+	f.VarP(&opts.Sort, "sort", "s", "sort output by (name|size|time=mtime|atime|ctime|extension)")
+	f.BoolVar(&opts.Reverse, "reverse", false, "reverse sorted output")
 }
 
 type lsPrinter interface {
diff --git a/cmd/restic/cmd_migrate.go b/cmd/restic/cmd_migrate.go
index f6c28e383..d4e7d0ac1 100644
--- a/cmd/restic/cmd_migrate.go
+++ b/cmd/restic/cmd_migrate.go
@@ -9,12 +9,16 @@ import (
 	"github.com/restic/restic/internal/ui/termstatus"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdMigrate = &cobra.Command{
-	Use:   "migrate [flags] [migration name] [...]",
-	Short: "Apply migrations",
-	Long: `
+func newMigrateCommand() *cobra.Command {
+	var opts MigrateOptions
+
+	cmd := &cobra.Command{
+		Use:   "migrate [flags] [migration name] [...]",
+		Short: "Apply migrations",
+		Long: `
 The "migrate" command checks which migrations can be applied for a repository
 and prints a list with available migration names. If one or more migration
 names are specified, these migrations are applied.
@@ -28,13 +32,17 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	GroupID:           cmdGroupDefault,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runMigrate(cmd.Context(), migrateOptions, globalOptions, args, term)
-	},
+		DisableAutoGenTag: true,
+		GroupID:           cmdGroupDefault,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runMigrate(cmd.Context(), opts, globalOptions, args, term)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // MigrateOptions bundles all options for the 'check' command.
@@ -42,12 +50,8 @@ type MigrateOptions struct {
 	Force bool
 }
 
-var migrateOptions MigrateOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdMigrate)
-	f := cmdMigrate.Flags()
-	f.BoolVarP(&migrateOptions.Force, "force", "f", false, `apply a migration a second time`)
+func (opts *MigrateOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVarP(&opts.Force, "force", "f", false, `apply a migration a second time`)
 }
 
 func checkMigrations(ctx context.Context, repo restic.Repository, printer progress.Printer) error {
diff --git a/cmd/restic/cmd_mount.go b/cmd/restic/cmd_mount.go
index b8a66dc90..15760c6b7 100644
--- a/cmd/restic/cmd_mount.go
+++ b/cmd/restic/cmd_mount.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
@@ -21,10 +22,17 @@ import (
 	"github.com/anacrolix/fuse/fs"
 )
 
-var cmdMount = &cobra.Command{
-	Use:   "mount [flags] mountpoint",
-	Short: "Mount the repository",
-	Long: `
+func registerMountCommand(cmdRoot *cobra.Command) {
+	cmdRoot.AddCommand(newMountCommand())
+}
+
+func newMountCommand() *cobra.Command {
+	var opts MountOptions
+
+	cmd := &cobra.Command{
+		Use:   "mount [flags] mountpoint",
+		Short: "Mount the repository",
+		Long: `
 The "mount" command mounts the repository via fuse to a directory. This is a
 read-only mount.
 
@@ -69,11 +77,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	GroupID:           cmdGroupDefault,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runMount(cmd.Context(), mountOptions, globalOptions, args)
-	},
+		DisableAutoGenTag: true,
+		GroupID:           cmdGroupDefault,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runMount(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // MountOptions collects all options for the mount command.
@@ -86,22 +98,17 @@ type MountOptions struct {
 	PathTemplates []string
 }
 
-var mountOptions MountOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdMount)
-
-	mountFlags := cmdMount.Flags()
-	mountFlags.BoolVar(&mountOptions.OwnerRoot, "owner-root", false, "use 'root' as the owner of files and dirs")
-	mountFlags.BoolVar(&mountOptions.AllowOther, "allow-other", false, "allow other users to access the data in the mounted directory")
-	mountFlags.BoolVar(&mountOptions.NoDefaultPermissions, "no-default-permissions", false, "for 'allow-other', ignore Unix permissions and allow users to read all snapshot files")
+func (opts *MountOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVar(&opts.OwnerRoot, "owner-root", false, "use 'root' as the owner of files and dirs")
+	f.BoolVar(&opts.AllowOther, "allow-other", false, "allow other users to access the data in the mounted directory")
+	f.BoolVar(&opts.NoDefaultPermissions, "no-default-permissions", false, "for 'allow-other', ignore Unix permissions and allow users to read all snapshot files")
 
-	initMultiSnapshotFilter(mountFlags, &mountOptions.SnapshotFilter, true)
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
 
-	mountFlags.StringArrayVar(&mountOptions.PathTemplates, "path-template", nil, "set `template` for path names (can be specified multiple times)")
-	mountFlags.StringVar(&mountOptions.TimeTemplate, "snapshot-template", time.RFC3339, "set `template` to use for snapshot dirs")
-	mountFlags.StringVar(&mountOptions.TimeTemplate, "time-template", time.RFC3339, "set `template` to use for times")
-	_ = mountFlags.MarkDeprecated("snapshot-template", "use --time-template")
+	f.StringArrayVar(&opts.PathTemplates, "path-template", nil, "set `template` for path names (can be specified multiple times)")
+	f.StringVar(&opts.TimeTemplate, "snapshot-template", time.RFC3339, "set `template` to use for snapshot dirs")
+	f.StringVar(&opts.TimeTemplate, "time-template", time.RFC3339, "set `template` to use for times")
+	_ = f.MarkDeprecated("snapshot-template", "use --time-template")
 }
 
 func runMount(ctx context.Context, opts MountOptions, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_mount_disabled.go b/cmd/restic/cmd_mount_disabled.go
new file mode 100644
index 000000000..8b83905e7
--- /dev/null
+++ b/cmd/restic/cmd_mount_disabled.go
@@ -0,0 +1,10 @@
+//go:build !darwin && !freebsd && !linux
+// +build !darwin,!freebsd,!linux
+
+package main
+
+import "github.com/spf13/cobra"
+
+func registerMountCommand(_ *cobra.Command) {
+	// Mount command not supported on these platforms
+}
diff --git a/cmd/restic/cmd_options.go b/cmd/restic/cmd_options.go
index 9c07b2626..34cf8656e 100644
--- a/cmd/restic/cmd_options.go
+++ b/cmd/restic/cmd_options.go
@@ -8,10 +8,11 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var optionsCmd = &cobra.Command{
-	Use:   "options",
-	Short: "Print list of extended options",
-	Long: `
+func newOptionsCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "options",
+		Short: "Print list of extended options",
+		Long: `
 The "options" command prints a list of extended options.
 
 EXIT STATUS
@@ -20,22 +21,20 @@ EXIT STATUS
 Exit status is 0 if the command was successful.
 Exit status is 1 if there was any error.
 `,
-	GroupID:           cmdGroupAdvanced,
-	DisableAutoGenTag: true,
-	Run: func(_ *cobra.Command, _ []string) {
-		fmt.Printf("All Extended Options:\n")
-		var maxLen int
-		for _, opt := range options.List() {
-			if l := len(opt.Namespace + "." + opt.Name); l > maxLen {
-				maxLen = l
+		GroupID:           cmdGroupAdvanced,
+		DisableAutoGenTag: true,
+		Run: func(_ *cobra.Command, _ []string) {
+			fmt.Printf("All Extended Options:\n")
+			var maxLen int
+			for _, opt := range options.List() {
+				if l := len(opt.Namespace + "." + opt.Name); l > maxLen {
+					maxLen = l
+				}
 			}
-		}
-		for _, opt := range options.List() {
-			fmt.Printf("  %*s  %s\n", -maxLen, opt.Namespace+"."+opt.Name, opt.Text)
-		}
-	},
-}
-
-func init() {
-	cmdRoot.AddCommand(optionsCmd)
+			for _, opt := range options.List() {
+				fmt.Printf("  %*s  %s\n", -maxLen, opt.Namespace+"."+opt.Name, opt.Text)
+			}
+		},
+	}
+	return cmd
 }
diff --git a/cmd/restic/cmd_prune.go b/cmd/restic/cmd_prune.go
index fce109bdd..a613f2255 100644
--- a/cmd/restic/cmd_prune.go
+++ b/cmd/restic/cmd_prune.go
@@ -16,12 +16,16 @@ import (
 	"github.com/restic/restic/internal/ui/termstatus"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdPrune = &cobra.Command{
-	Use:   "prune [flags]",
-	Short: "Remove unneeded data from the repository",
-	Long: `
+func newPruneCommand() *cobra.Command {
+	var opts PruneOptions
+
+	cmd := &cobra.Command{
+		Use:   "prune [flags]",
+		Short: "Remove unneeded data from the repository",
+		Long: `
 The "prune" command checks the repository and removes data that is not
 referenced and therefore not needed any more.
 
@@ -34,13 +38,17 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, _ []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runPrune(cmd.Context(), pruneOptions, globalOptions, term)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runPrune(cmd.Context(), opts, globalOptions, term)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // PruneOptions collects all options for the cleanup command.
@@ -61,23 +69,18 @@ type PruneOptions struct {
 	RepackUncompressed  bool
 }
 
-var pruneOptions PruneOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdPrune)
-	f := cmdPrune.Flags()
-	f.BoolVarP(&pruneOptions.DryRun, "dry-run", "n", false, "do not modify the repository, just print what would be done")
-	f.StringVarP(&pruneOptions.UnsafeNoSpaceRecovery, "unsafe-recover-no-free-space", "", "", "UNSAFE, READ THE DOCUMENTATION BEFORE USING! Try to recover a repository stuck with no free space. Do not use without trying out 'prune --max-repack-size 0' first.")
-	addPruneOptions(cmdPrune, &pruneOptions)
+func (opts *PruneOptions) AddFlags(f *pflag.FlagSet) {
+	opts.AddLimitedFlags(f)
+	f.BoolVarP(&opts.DryRun, "dry-run", "n", false, "do not modify the repository, just print what would be done")
+	f.StringVarP(&opts.UnsafeNoSpaceRecovery, "unsafe-recover-no-free-space", "", "", "UNSAFE, READ THE DOCUMENTATION BEFORE USING! Try to recover a repository stuck with no free space. Do not use without trying out 'prune --max-repack-size 0' first.")
 }
 
-func addPruneOptions(c *cobra.Command, pruneOptions *PruneOptions) {
-	f := c.Flags()
-	f.StringVar(&pruneOptions.MaxUnused, "max-unused", "5%", "tolerate given `limit` of unused data (absolute value in bytes with suffixes k/K, m/M, g/G, t/T, a value in % or the word 'unlimited')")
-	f.StringVar(&pruneOptions.MaxRepackSize, "max-repack-size", "", "stop after repacking this much data in total (allowed suffixes for `size`: k/K, m/M, g/G, t/T)")
-	f.BoolVar(&pruneOptions.RepackCacheableOnly, "repack-cacheable-only", false, "only repack packs which are cacheable")
-	f.BoolVar(&pruneOptions.RepackSmall, "repack-small", false, "repack pack files below 80% of target pack size")
-	f.BoolVar(&pruneOptions.RepackUncompressed, "repack-uncompressed", false, "repack all uncompressed data")
+func (opts *PruneOptions) AddLimitedFlags(f *pflag.FlagSet) {
+	f.StringVar(&opts.MaxUnused, "max-unused", "5%", "tolerate given `limit` of unused data (absolute value in bytes with suffixes k/K, m/M, g/G, t/T, a value in % or the word 'unlimited')")
+	f.StringVar(&opts.MaxRepackSize, "max-repack-size", "", "stop after repacking this much data in total (allowed suffixes for `size`: k/K, m/M, g/G, t/T)")
+	f.BoolVar(&opts.RepackCacheableOnly, "repack-cacheable-only", false, "only repack packs which are cacheable")
+	f.BoolVar(&opts.RepackSmall, "repack-small", false, "repack pack files below 80% of target pack size")
+	f.BoolVar(&opts.RepackUncompressed, "repack-uncompressed", false, "repack all uncompressed data")
 }
 
 func verifyPruneOptions(opts *PruneOptions) error {
diff --git a/cmd/restic/cmd_recover.go b/cmd/restic/cmd_recover.go
index 78fc2d148..10284c111 100644
--- a/cmd/restic/cmd_recover.go
+++ b/cmd/restic/cmd_recover.go
@@ -11,10 +11,11 @@ import (
 	"golang.org/x/sync/errgroup"
 )
 
-var cmdRecover = &cobra.Command{
-	Use:   "recover [flags]",
-	Short: "Recover data from the repository not referenced by snapshots",
-	Long: `
+func newRecoverCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "recover [flags]",
+		Short: "Recover data from the repository not referenced by snapshots",
+		Long: `
 The "recover" command builds a new snapshot from all directories it can find in
 the raw data of the repository which are not referenced in an existing snapshot.
 It can be used if, for example, a snapshot has been removed by accident with "forget".
@@ -28,15 +29,13 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, _ []string) error {
-		return runRecover(cmd.Context(), globalOptions)
-	},
-}
-
-func init() {
-	cmdRoot.AddCommand(cmdRecover)
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			return runRecover(cmd.Context(), globalOptions)
+		},
+	}
+	return cmd
 }
 
 func runRecover(ctx context.Context, gopts GlobalOptions) error {
diff --git a/cmd/restic/cmd_repair.go b/cmd/restic/cmd_repair.go
index 6a1a1f9dc..bb1c98d27 100644
--- a/cmd/restic/cmd_repair.go
+++ b/cmd/restic/cmd_repair.go
@@ -4,13 +4,18 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var cmdRepair = &cobra.Command{
-	Use:               "repair",
-	Short:             "Repair the repository",
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-}
+func newRepairCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "repair",
+		Short:             "Repair the repository",
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+	}
 
-func init() {
-	cmdRoot.AddCommand(cmdRepair)
+	cmd.AddCommand(
+		newRepairIndexCommand(),
+		newRepairPacksCommand(),
+		newRepairSnapshotsCommand(),
+	)
+	return cmd
 }
diff --git a/cmd/restic/cmd_repair_index.go b/cmd/restic/cmd_repair_index.go
index 83c1bfa7f..079b322a9 100644
--- a/cmd/restic/cmd_repair_index.go
+++ b/cmd/restic/cmd_repair_index.go
@@ -9,10 +9,13 @@ import (
 	"github.com/spf13/pflag"
 )
 
-var cmdRepairIndex = &cobra.Command{
-	Use:   "index [flags]",
-	Short: "Build a new index",
-	Long: `
+func newRepairIndexCommand() *cobra.Command {
+	var opts RepairIndexOptions
+
+	cmd := &cobra.Command{
+		Use:   "index [flags]",
+		Short: "Build a new index",
+		Long: `
 The "repair index" command creates a new index based on the pack files in the
 repository.
 
@@ -25,21 +28,16 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, _ []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runRebuildIndex(cmd.Context(), repairIndexOptions, globalOptions, term)
-	},
-}
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runRebuildIndex(cmd.Context(), opts, globalOptions, term)
+		},
+	}
 
-var cmdRebuildIndex = &cobra.Command{
-	Use:               "rebuild-index [flags]",
-	Short:             cmdRepairIndex.Short,
-	Long:              cmdRepairIndex.Long,
-	Deprecated:        `Use "repair index" instead`,
-	DisableAutoGenTag: true,
-	RunE:              cmdRepairIndex.RunE,
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // RepairIndexOptions collects all options for the repair index command.
@@ -47,16 +45,31 @@ type RepairIndexOptions struct {
 	ReadAllPacks bool
 }
 
-var repairIndexOptions RepairIndexOptions
+func (opts *RepairIndexOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVar(&opts.ReadAllPacks, "read-all-packs", false, "read all pack files to generate new index from scratch")
+}
 
-func init() {
-	cmdRepair.AddCommand(cmdRepairIndex)
-	// add alias for old name
-	cmdRoot.AddCommand(cmdRebuildIndex)
+func newRebuildIndexCommand() *cobra.Command {
+	var opts RepairIndexOptions
 
-	for _, f := range []*pflag.FlagSet{cmdRepairIndex.Flags(), cmdRebuildIndex.Flags()} {
-		f.BoolVar(&repairIndexOptions.ReadAllPacks, "read-all-packs", false, "read all pack files to generate new index from scratch")
+	replacement := newRepairIndexCommand()
+	cmd := &cobra.Command{
+		Use:               "rebuild-index [flags]",
+		Short:             replacement.Short,
+		Long:              replacement.Long,
+		Deprecated:        `Use "repair index" instead`,
+		DisableAutoGenTag: true,
+		// must create a new instance of the run function as it captures opts
+		// by reference
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runRebuildIndex(cmd.Context(), opts, globalOptions, term)
+		},
 	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 func runRebuildIndex(ctx context.Context, opts RepairIndexOptions, gopts GlobalOptions, term *termstatus.Terminal) error {
diff --git a/cmd/restic/cmd_repair_packs.go b/cmd/restic/cmd_repair_packs.go
index 290c3734e..aaaa2f08f 100644
--- a/cmd/restic/cmd_repair_packs.go
+++ b/cmd/restic/cmd_repair_packs.go
@@ -13,10 +13,11 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var cmdRepairPacks = &cobra.Command{
-	Use:   "packs [packIDs...]",
-	Short: "Salvage damaged pack files",
-	Long: `
+func newRepairPacksCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "packs [packIDs...]",
+		Short: "Salvage damaged pack files",
+		Long: `
 The "repair packs" command extracts intact blobs from the specified pack files, rebuilds
 the index to remove the damaged pack files and removes the pack files from the repository.
 
@@ -29,16 +30,14 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runRepairPacks(cmd.Context(), globalOptions, term, args)
-	},
-}
-
-func init() {
-	cmdRepair.AddCommand(cmdRepairPacks)
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runRepairPacks(cmd.Context(), globalOptions, term, args)
+		},
+	}
+	return cmd
 }
 
 func runRepairPacks(ctx context.Context, gopts GlobalOptions, term *termstatus.Terminal, args []string) error {
diff --git a/cmd/restic/cmd_repair_snapshots.go b/cmd/restic/cmd_repair_snapshots.go
index 34c02b3ff..ecca7900e 100644
--- a/cmd/restic/cmd_repair_snapshots.go
+++ b/cmd/restic/cmd_repair_snapshots.go
@@ -8,12 +8,16 @@ import (
 	"github.com/restic/restic/internal/walker"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdRepairSnapshots = &cobra.Command{
-	Use:   "snapshots [flags] [snapshot ID] [...]",
-	Short: "Repair snapshots",
-	Long: `
+func newRepairSnapshotsCommand() *cobra.Command {
+	var opts RepairOptions
+
+	cmd := &cobra.Command{
+		Use:   "snapshots [flags] [snapshot ID] [...]",
+		Short: "Repair snapshots",
+		Long: `
 The "repair snapshots" command repairs broken snapshots. It scans the given
 snapshots and generates new ones with damaged directories and file contents
 removed. If the broken snapshots are deleted, a prune run will be able to
@@ -43,10 +47,14 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runRepairSnapshots(cmd.Context(), globalOptions, repairSnapshotOptions, args)
-	},
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runRepairSnapshots(cmd.Context(), globalOptions, opts, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // RepairOptions collects all options for the repair command.
@@ -57,16 +65,11 @@ type RepairOptions struct {
 	restic.SnapshotFilter
 }
 
-var repairSnapshotOptions RepairOptions
-
-func init() {
-	cmdRepair.AddCommand(cmdRepairSnapshots)
-	flags := cmdRepairSnapshots.Flags()
-
-	flags.BoolVarP(&repairSnapshotOptions.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done")
-	flags.BoolVarP(&repairSnapshotOptions.Forget, "forget", "", false, "remove original snapshots after creating new ones")
+func (opts *RepairOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVarP(&opts.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done")
+	f.BoolVarP(&opts.Forget, "forget", "", false, "remove original snapshots after creating new ones")
 
-	initMultiSnapshotFilter(flags, &repairSnapshotOptions.SnapshotFilter, true)
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
 }
 
 func runRepairSnapshots(ctx context.Context, gopts GlobalOptions, opts RepairOptions, args []string) error {
diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go
index c930abc31..a29b8a19e 100644
--- a/cmd/restic/cmd_restore.go
+++ b/cmd/restic/cmd_restore.go
@@ -15,12 +15,16 @@ import (
 	"github.com/restic/restic/internal/ui/termstatus"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdRestore = &cobra.Command{
-	Use:   "restore [flags] snapshotID",
-	Short: "Extract the data from a snapshot",
-	Long: `
+func newRestoreCommand() *cobra.Command {
+	var opts RestoreOptions
+
+	cmd := &cobra.Command{
+		Use:   "restore [flags] snapshotID",
+		Short: "Extract the data from a snapshot",
+		Long: `
 The "restore" command extracts the data from a snapshot from the repository to
 a directory.
 
@@ -39,13 +43,17 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runRestore(cmd.Context(), restoreOptions, globalOptions, term, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runRestore(cmd.Context(), opts, globalOptions, term, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // RestoreOptions collects all options for the restore command.
@@ -63,26 +71,21 @@ type RestoreOptions struct {
 	IncludeXattrPattern []string
 }
 
-var restoreOptions RestoreOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdRestore)
-
-	flags := cmdRestore.Flags()
-	flags.StringVarP(&restoreOptions.Target, "target", "t", "", "directory to extract data to")
+func (opts *RestoreOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVarP(&opts.Target, "target", "t", "", "directory to extract data to")
 
-	restoreOptions.ExcludePatternOptions.Add(flags)
-	restoreOptions.IncludePatternOptions.Add(flags)
+	opts.ExcludePatternOptions.Add(f)
+	opts.IncludePatternOptions.Add(f)
 
-	flags.StringArrayVar(&restoreOptions.ExcludeXattrPattern, "exclude-xattr", nil, "exclude xattr by `pattern` (can be specified multiple times)")
-	flags.StringArrayVar(&restoreOptions.IncludeXattrPattern, "include-xattr", nil, "include xattr by `pattern` (can be specified multiple times)")
+	f.StringArrayVar(&opts.ExcludeXattrPattern, "exclude-xattr", nil, "exclude xattr by `pattern` (can be specified multiple times)")
+	f.StringArrayVar(&opts.IncludeXattrPattern, "include-xattr", nil, "include xattr by `pattern` (can be specified multiple times)")
 
-	initSingleSnapshotFilter(flags, &restoreOptions.SnapshotFilter)
-	flags.BoolVar(&restoreOptions.DryRun, "dry-run", false, "do not write any data, just show what would be done")
-	flags.BoolVar(&restoreOptions.Sparse, "sparse", false, "restore files as sparse")
-	flags.BoolVar(&restoreOptions.Verify, "verify", false, "verify restored files content")
-	flags.Var(&restoreOptions.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never) (default: always)")
-	flags.BoolVar(&restoreOptions.Delete, "delete", false, "delete files from target directory if they do not exist in snapshot. Use '--dry-run -vv' to check what would be deleted")
+	initSingleSnapshotFilter(f, &opts.SnapshotFilter)
+	f.BoolVar(&opts.DryRun, "dry-run", false, "do not write any data, just show what would be done")
+	f.BoolVar(&opts.Sparse, "sparse", false, "restore files as sparse")
+	f.BoolVar(&opts.Verify, "verify", false, "verify restored files content")
+	f.Var(&opts.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never) (default: always)")
+	f.BoolVar(&opts.Delete, "delete", false, "delete files from target directory if they do not exist in snapshot. Use '--dry-run -vv' to check what would be deleted")
 }
 
 func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
diff --git a/cmd/restic/cmd_rewrite.go b/cmd/restic/cmd_rewrite.go
index f847aa372..4e5b39932 100644
--- a/cmd/restic/cmd_rewrite.go
+++ b/cmd/restic/cmd_rewrite.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 	"golang.org/x/sync/errgroup"
 
 	"github.com/restic/restic/internal/debug"
@@ -15,10 +16,13 @@ import (
 	"github.com/restic/restic/internal/walker"
 )
 
-var cmdRewrite = &cobra.Command{
-	Use:   "rewrite [flags] [snapshotID ...]",
-	Short: "Rewrite snapshots to exclude unwanted files",
-	Long: `
+func newRewriteCommand() *cobra.Command {
+	var opts RewriteOptions
+
+	cmd := &cobra.Command{
+		Use:   "rewrite [flags] [snapshotID ...]",
+		Short: "Rewrite snapshots to exclude unwanted files",
+		Long: `
 The "rewrite" command excludes files from existing snapshots. It creates new
 snapshots containing the same data as the original ones, but without the files
 you specify to exclude. All metadata (time, host, tags) will be preserved.
@@ -51,11 +55,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runRewrite(cmd.Context(), rewriteOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runRewrite(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 type snapshotMetadata struct {
@@ -99,20 +107,15 @@ type RewriteOptions struct {
 	filter.ExcludePatternOptions
 }
 
-var rewriteOptions RewriteOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdRewrite)
-
-	f := cmdRewrite.Flags()
-	f.BoolVarP(&rewriteOptions.Forget, "forget", "", false, "remove original snapshots after creating new ones")
-	f.BoolVarP(&rewriteOptions.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done")
-	f.StringVar(&rewriteOptions.Metadata.Hostname, "new-host", "", "replace hostname")
-	f.StringVar(&rewriteOptions.Metadata.Time, "new-time", "", "replace time of the backup")
-	f.BoolVarP(&rewriteOptions.SnapshotSummary, "snapshot-summary", "s", false, "create snapshot summary record if it does not exist")
+func (opts *RewriteOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVarP(&opts.Forget, "forget", "", false, "remove original snapshots after creating new ones")
+	f.BoolVarP(&opts.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done")
+	f.StringVar(&opts.Metadata.Hostname, "new-host", "", "replace hostname")
+	f.StringVar(&opts.Metadata.Time, "new-time", "", "replace time of the backup")
+	f.BoolVarP(&opts.SnapshotSummary, "snapshot-summary", "s", false, "create snapshot summary record if it does not exist")
 
-	initMultiSnapshotFilter(f, &rewriteOptions.SnapshotFilter, true)
-	rewriteOptions.ExcludePatternOptions.Add(f)
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
+	opts.ExcludePatternOptions.Add(f)
 }
 
 // rewriteFilterFunc returns the filtered tree ID or an error. If a snapshot summary is returned, the snapshot will
diff --git a/cmd/restic/cmd_self_update.go b/cmd/restic/cmd_self_update.go
index 09c86bf2c..2247274ac 100644
--- a/cmd/restic/cmd_self_update.go
+++ b/cmd/restic/cmd_self_update.go
@@ -10,12 +10,22 @@ import (
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/selfupdate"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdSelfUpdate = &cobra.Command{
-	Use:   "self-update [flags]",
-	Short: "Update the restic binary",
-	Long: `
+func registerSelfUpdateCommand(cmd *cobra.Command) {
+	cmd.AddCommand(
+		newSelfUpdateCommand(),
+	)
+}
+
+func newSelfUpdateCommand() *cobra.Command {
+	var opts SelfUpdateOptions
+
+	cmd := &cobra.Command{
+		Use:   "self-update [flags]",
+		Short: "Update the restic binary",
+		Long: `
 The command "self-update" downloads the latest stable release of restic from
 GitHub and replaces the currently running binary. After download, the
 authenticity of the binary is verified using the GPG signature on the release
@@ -30,10 +40,14 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runSelfUpdate(cmd.Context(), selfUpdateOptions, globalOptions, args)
-	},
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runSelfUpdate(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // SelfUpdateOptions collects all options for the update-restic command.
@@ -41,13 +55,8 @@ type SelfUpdateOptions struct {
 	Output string
 }
 
-var selfUpdateOptions SelfUpdateOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdSelfUpdate)
-
-	flags := cmdSelfUpdate.Flags()
-	flags.StringVar(&selfUpdateOptions.Output, "output", "", "Save the downloaded file as `filename` (default: running binary itself)")
+func (opts *SelfUpdateOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVar(&opts.Output, "output", "", "Save the downloaded file as `filename` (default: running binary itself)")
 }
 
 func runSelfUpdate(ctx context.Context, opts SelfUpdateOptions, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_self_update_disabled.go b/cmd/restic/cmd_self_update_disabled.go
new file mode 100644
index 000000000..ce9af10d2
--- /dev/null
+++ b/cmd/restic/cmd_self_update_disabled.go
@@ -0,0 +1,9 @@
+//go:build !selfupdate
+
+package main
+
+import "github.com/spf13/cobra"
+
+func registerSelfUpdateCommand(_ *cobra.Command) {
+	// No commands to register in non-selfupdate mode
+}
diff --git a/cmd/restic/cmd_snapshots.go b/cmd/restic/cmd_snapshots.go
index f935cec86..65e5753c3 100644
--- a/cmd/restic/cmd_snapshots.go
+++ b/cmd/restic/cmd_snapshots.go
@@ -12,12 +12,16 @@ import (
 	"github.com/restic/restic/internal/ui"
 	"github.com/restic/restic/internal/ui/table"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdSnapshots = &cobra.Command{
-	Use:   "snapshots [flags] [snapshotID ...]",
-	Short: "List all snapshots",
-	Long: `
+func newSnapshotsCommand() *cobra.Command {
+	var opts SnapshotOptions
+
+	cmd := &cobra.Command{
+		Use:   "snapshots [flags] [snapshotID ...]",
+		Short: "List all snapshots",
+		Long: `
 The "snapshots" command lists all snapshots stored in the repository.
 
 EXIT STATUS
@@ -29,11 +33,15 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runSnapshots(cmd.Context(), snapshotOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runSnapshots(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // SnapshotOptions bundles all options for the snapshots command.
@@ -45,22 +53,17 @@ type SnapshotOptions struct {
 	GroupBy restic.SnapshotGroupByOptions
 }
 
-var snapshotOptions SnapshotOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdSnapshots)
-
-	f := cmdSnapshots.Flags()
-	initMultiSnapshotFilter(f, &snapshotOptions.SnapshotFilter, true)
-	f.BoolVarP(&snapshotOptions.Compact, "compact", "c", false, "use compact output format")
-	f.BoolVar(&snapshotOptions.Last, "last", false, "only show the last snapshot for each host and path")
+func (opts *SnapshotOptions) AddFlags(f *pflag.FlagSet) {
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
+	f.BoolVarP(&opts.Compact, "compact", "c", false, "use compact output format")
+	f.BoolVar(&opts.Last, "last", false, "only show the last snapshot for each host and path")
 	err := f.MarkDeprecated("last", "use --latest 1")
 	if err != nil {
 		// MarkDeprecated only returns an error when the flag is not found
 		panic(err)
 	}
-	f.IntVar(&snapshotOptions.Latest, "latest", 0, "only show the last `n` snapshots for each host and path")
-	f.VarP(&snapshotOptions.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma")
+	f.IntVar(&opts.Latest, "latest", 0, "only show the last `n` snapshots for each host and path")
+	f.VarP(&opts.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma")
 }
 
 func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts GlobalOptions, args []string) error {
diff --git a/cmd/restic/cmd_stats.go b/cmd/restic/cmd_stats.go
index e0b60a29e..1e7df11cb 100644
--- a/cmd/restic/cmd_stats.go
+++ b/cmd/restic/cmd_stats.go
@@ -18,12 +18,16 @@ import (
 	"github.com/restic/restic/internal/walker"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var cmdStats = &cobra.Command{
-	Use:   "stats [flags] [snapshot ID] [...]",
-	Short: "Scan the repository and show basic statistics",
-	Long: `
+func newStatsCommand() *cobra.Command {
+	var opts StatsOptions
+
+	cmd := &cobra.Command{
+		Use:   "stats [flags] [snapshot ID] [...]",
+		Short: "Scan the repository and show basic statistics",
+		Long: `
 The "stats" command walks one or multiple snapshots in a repository
 and accumulates statistics about the data stored therein. It reports 
 on the number of unique files and their sizes, according to one of
@@ -55,11 +59,18 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		return runStats(cmd.Context(), statsOptions, globalOptions, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runStats(cmd.Context(), opts, globalOptions, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	must(cmd.RegisterFlagCompletionFunc("mode", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+		return []string{countModeRestoreSize, countModeUniqueFilesByContents, countModeBlobsPerFile, countModeRawData}, cobra.ShellCompDirectiveDefault
+	}))
+	return cmd
 }
 
 // StatsOptions collects all options for the stats command.
@@ -70,7 +81,10 @@ type StatsOptions struct {
 	restic.SnapshotFilter
 }
 
-var statsOptions StatsOptions
+func (opts *StatsOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVar(&opts.countMode, "mode", countModeRestoreSize, "counting mode: restore-size (default), files-by-contents, blobs-per-file or raw-data")
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
+}
 
 func must(err error) {
 	if err != nil {
@@ -78,17 +92,6 @@ func must(err error) {
 	}
 }
 
-func init() {
-	cmdRoot.AddCommand(cmdStats)
-	f := cmdStats.Flags()
-	f.StringVar(&statsOptions.countMode, "mode", countModeRestoreSize, "counting mode: restore-size (default), files-by-contents, blobs-per-file or raw-data")
-	must(cmdStats.RegisterFlagCompletionFunc("mode", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-		return []string{countModeRestoreSize, countModeUniqueFilesByContents, countModeBlobsPerFile, countModeRawData}, cobra.ShellCompDirectiveDefault
-	}))
-
-	initMultiSnapshotFilter(f, &statsOptions.SnapshotFilter, true)
-}
-
 func runStats(ctx context.Context, opts StatsOptions, gopts GlobalOptions, args []string) error {
 	err := verifyStatsInput(opts)
 	if err != nil {
diff --git a/cmd/restic/cmd_tag.go b/cmd/restic/cmd_tag.go
index f71e2556c..39e9a16b5 100644
--- a/cmd/restic/cmd_tag.go
+++ b/cmd/restic/cmd_tag.go
@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
@@ -13,10 +14,13 @@ import (
 	"github.com/restic/restic/internal/ui/termstatus"
 )
 
-var cmdTag = &cobra.Command{
-	Use:   "tag [flags] [snapshotID ...]",
-	Short: "Modify tags on snapshots",
-	Long: `
+func newTagCommand() *cobra.Command {
+	var opts TagOptions
+
+	cmd := &cobra.Command{
+		Use:   "tag [flags] [snapshotID ...]",
+		Short: "Modify tags on snapshots",
+		Long: `
 The "tag" command allows you to modify tags on exiting snapshots.
 
 You can either set/replace the entire set of tags on a snapshot, or
@@ -33,13 +37,17 @@ Exit status is 10 if the repository does not exist.
 Exit status is 11 if the repository is already locked.
 Exit status is 12 if the password is incorrect.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runTag(cmd.Context(), tagOptions, globalOptions, term, args)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			term, cancel := setupTermstatus()
+			defer cancel()
+			return runTag(cmd.Context(), opts, globalOptions, term, args)
+		},
+	}
+
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // TagOptions bundles all options for the 'tag' command.
@@ -50,16 +58,11 @@ type TagOptions struct {
 	RemoveTags restic.TagLists
 }
 
-var tagOptions TagOptions
-
-func init() {
-	cmdRoot.AddCommand(cmdTag)
-
-	tagFlags := cmdTag.Flags()
-	tagFlags.Var(&tagOptions.SetTags, "set", "`tags` which will replace the existing tags in the format `tag[,tag,...]` (can be given multiple times)")
-	tagFlags.Var(&tagOptions.AddTags, "add", "`tags` which will be added to the existing tags in the format `tag[,tag,...]` (can be given multiple times)")
-	tagFlags.Var(&tagOptions.RemoveTags, "remove", "`tags` which will be removed from the existing tags in the format `tag[,tag,...]` (can be given multiple times)")
-	initMultiSnapshotFilter(tagFlags, &tagOptions.SnapshotFilter, true)
+func (opts *TagOptions) AddFlags(f *pflag.FlagSet) {
+	f.Var(&opts.SetTags, "set", "`tags` which will replace the existing tags in the format `tag[,tag,...]` (can be given multiple times)")
+	f.Var(&opts.AddTags, "add", "`tags` which will be added to the existing tags in the format `tag[,tag,...]` (can be given multiple times)")
+	f.Var(&opts.RemoveTags, "remove", "`tags` which will be removed from the existing tags in the format `tag[,tag,...]` (can be given multiple times)")
+	initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
 }
 
 type changedSnapshot struct {
diff --git a/cmd/restic/cmd_unlock.go b/cmd/restic/cmd_unlock.go
index 825eb815c..5932ffcaa 100644
--- a/cmd/restic/cmd_unlock.go
+++ b/cmd/restic/cmd_unlock.go
@@ -5,12 +5,16 @@ import (
 
 	"github.com/restic/restic/internal/repository"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
-var unlockCmd = &cobra.Command{
-	Use:   "unlock",
-	Short: "Remove locks other processes created",
-	Long: `
+func newUnlockCommand() *cobra.Command {
+	var opts UnlockOptions
+
+	cmd := &cobra.Command{
+		Use:   "unlock",
+		Short: "Remove locks other processes created",
+		Long: `
 The "unlock" command removes stale locks that have been created by other restic processes.
 
 EXIT STATUS
@@ -19,11 +23,14 @@ EXIT STATUS
 Exit status is 0 if the command was successful.
 Exit status is 1 if there was any error.
 `,
-	GroupID:           cmdGroupDefault,
-	DisableAutoGenTag: true,
-	RunE: func(cmd *cobra.Command, _ []string) error {
-		return runUnlock(cmd.Context(), unlockOptions, globalOptions)
-	},
+		GroupID:           cmdGroupDefault,
+		DisableAutoGenTag: true,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			return runUnlock(cmd.Context(), opts, globalOptions)
+		},
+	}
+	opts.AddFlags(cmd.Flags())
+	return cmd
 }
 
 // UnlockOptions collects all options for the unlock command.
@@ -31,12 +38,8 @@ type UnlockOptions struct {
 	RemoveAll bool
 }
 
-var unlockOptions UnlockOptions
-
-func init() {
-	cmdRoot.AddCommand(unlockCmd)
-
-	unlockCmd.Flags().BoolVar(&unlockOptions.RemoveAll, "remove-all", false, "remove all locks, even non-stale ones")
+func (opts *UnlockOptions) AddFlags(f *pflag.FlagSet) {
+	f.BoolVar(&opts.RemoveAll, "remove-all", false, "remove all locks, even non-stale ones")
 }
 
 func runUnlock(ctx context.Context, opts UnlockOptions, gopts GlobalOptions) error {
diff --git a/cmd/restic/cmd_version.go b/cmd/restic/cmd_version.go
index daf984a85..533098b9b 100644
--- a/cmd/restic/cmd_version.go
+++ b/cmd/restic/cmd_version.go
@@ -8,10 +8,11 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var versionCmd = &cobra.Command{
-	Use:   "version",
-	Short: "Print version information",
-	Long: `
+func newVersionCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "version",
+		Short: "Print version information",
+		Long: `
 The "version" command prints detailed information about the build environment
 and the version of this software.
 
@@ -21,38 +22,36 @@ EXIT STATUS
 Exit status is 0 if the command was successful.
 Exit status is 1 if there was any error.
 `,
-	DisableAutoGenTag: true,
-	Run: func(_ *cobra.Command, _ []string) {
-		if globalOptions.JSON {
-			type jsonVersion struct {
-				MessageType string `json:"message_type"` // version
-				Version     string `json:"version"`
-				GoVersion   string `json:"go_version"`
-				GoOS        string `json:"go_os"`
-				GoArch      string `json:"go_arch"`
+		DisableAutoGenTag: true,
+		Run: func(_ *cobra.Command, _ []string) {
+			if globalOptions.JSON {
+				type jsonVersion struct {
+					MessageType string `json:"message_type"` // version
+					Version     string `json:"version"`
+					GoVersion   string `json:"go_version"`
+					GoOS        string `json:"go_os"`
+					GoArch      string `json:"go_arch"`
+				}
+
+				jsonS := jsonVersion{
+					MessageType: "version",
+					Version:     version,
+					GoVersion:   runtime.Version(),
+					GoOS:        runtime.GOOS,
+					GoArch:      runtime.GOARCH,
+				}
+
+				err := json.NewEncoder(globalOptions.stdout).Encode(jsonS)
+				if err != nil {
+					Warnf("JSON encode failed: %v\n", err)
+					return
+				}
+			} else {
+				fmt.Printf("restic %s compiled with %v on %v/%v\n",
+					version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
 			}
 
-			jsonS := jsonVersion{
-				MessageType: "version",
-				Version:     version,
-				GoVersion:   runtime.Version(),
-				GoOS:        runtime.GOOS,
-				GoArch:      runtime.GOARCH,
-			}
-
-			err := json.NewEncoder(globalOptions.stdout).Encode(jsonS)
-			if err != nil {
-				Warnf("JSON encode failed: %v\n", err)
-				return
-			}
-		} else {
-			fmt.Printf("restic %s compiled with %v on %v/%v\n",
-				version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
-		}
-
-	},
-}
-
-func init() {
-	cmdRoot.AddCommand(versionCmd)
+		},
+	}
+	return cmd
 }
diff --git a/cmd/restic/global.go b/cmd/restic/global.go
index bea09837f..066d0acef 100644
--- a/cmd/restic/global.go
+++ b/cmd/restic/global.go
@@ -34,6 +34,7 @@ import (
 	"github.com/restic/restic/internal/restic"
 	"github.com/restic/restic/internal/textfile"
 	"github.com/restic/restic/internal/ui/termstatus"
+	"github.com/spf13/pflag"
 
 	"github.com/restic/restic/internal/errors"
 
@@ -95,12 +96,97 @@ type GlobalOptions struct {
 	extended options.Options
 }
 
+func (opts *GlobalOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVarP(&opts.Repo, "repo", "r", "", "`repository` to backup to or restore from (default: $RESTIC_REPOSITORY)")
+	f.StringVarP(&opts.RepositoryFile, "repository-file", "", "", "`file` to read the repository location from (default: $RESTIC_REPOSITORY_FILE)")
+	f.StringVarP(&opts.PasswordFile, "password-file", "p", "", "`file` to read the repository password from (default: $RESTIC_PASSWORD_FILE)")
+	f.StringVarP(&opts.KeyHint, "key-hint", "", "", "`key` ID of key to try decrypting first (default: $RESTIC_KEY_HINT)")
+	f.StringVarP(&opts.PasswordCommand, "password-command", "", "", "shell `command` to obtain the repository password from (default: $RESTIC_PASSWORD_COMMAND)")
+	f.BoolVarP(&opts.Quiet, "quiet", "q", false, "do not output comprehensive progress report")
+	// use empty parameter name as `-v, --verbose n` instead of the correct `--verbose=n` is confusing
+	f.CountVarP(&opts.Verbose, "verbose", "v", "be verbose (specify multiple times or a level using --verbose=n``, max level/times is 2)")
+	f.BoolVar(&opts.NoLock, "no-lock", false, "do not lock the repository, this allows some operations on read-only repositories")
+	f.DurationVar(&opts.RetryLock, "retry-lock", 0, "retry to lock the repository if it is already locked, takes a value like 5m or 2h (default: no retries)")
+	f.BoolVarP(&opts.JSON, "json", "", false, "set output mode to JSON for commands that support it")
+	f.StringVar(&opts.CacheDir, "cache-dir", "", "set the cache `directory`. (default: use system default cache directory)")
+	f.BoolVar(&opts.NoCache, "no-cache", false, "do not use a local cache")
+	f.StringSliceVar(&opts.RootCertFilenames, "cacert", nil, "`file` to load root certificates from (default: use system certificates or $RESTIC_CACERT)")
+	f.StringVar(&opts.TLSClientCertKeyFilename, "tls-client-cert", "", "path to a `file` containing PEM encoded TLS client certificate and private key (default: $RESTIC_TLS_CLIENT_CERT)")
+	f.BoolVar(&opts.InsecureNoPassword, "insecure-no-password", false, "use an empty password for the repository, must be passed to every restic command (insecure)")
+	f.BoolVar(&opts.InsecureTLS, "insecure-tls", false, "skip TLS certificate verification when connecting to the repository (insecure)")
+	f.BoolVar(&opts.CleanupCache, "cleanup-cache", false, "auto remove old cache directories")
+	f.Var(&opts.Compression, "compression", "compression mode (only available for repository format version 2), one of (auto|off|max) (default: $RESTIC_COMPRESSION)")
+	f.BoolVar(&opts.NoExtraVerify, "no-extra-verify", false, "skip additional verification of data before upload (see documentation)")
+	f.IntVar(&opts.Limits.UploadKb, "limit-upload", 0, "limits uploads to a maximum `rate` in KiB/s. (default: unlimited)")
+	f.IntVar(&opts.Limits.DownloadKb, "limit-download", 0, "limits downloads to a maximum `rate` in KiB/s. (default: unlimited)")
+	f.UintVar(&opts.PackSize, "pack-size", 0, "set target pack `size` in MiB, created pack files may be larger (default: $RESTIC_PACK_SIZE)")
+	f.StringSliceVarP(&opts.Options, "option", "o", []string{}, "set extended option (`key=value`, can be specified multiple times)")
+	f.StringVar(&opts.HTTPUserAgent, "http-user-agent", "", "set a http user agent for outgoing http requests")
+	f.DurationVar(&opts.StuckRequestTimeout, "stuck-request-timeout", 5*time.Minute, "`duration` after which to retry stuck requests")
+
+	opts.Repo = os.Getenv("RESTIC_REPOSITORY")
+	opts.RepositoryFile = os.Getenv("RESTIC_REPOSITORY_FILE")
+	opts.PasswordFile = os.Getenv("RESTIC_PASSWORD_FILE")
+	opts.KeyHint = os.Getenv("RESTIC_KEY_HINT")
+	opts.PasswordCommand = os.Getenv("RESTIC_PASSWORD_COMMAND")
+	if os.Getenv("RESTIC_CACERT") != "" {
+		opts.RootCertFilenames = strings.Split(os.Getenv("RESTIC_CACERT"), ",")
+	}
+	opts.TLSClientCertKeyFilename = os.Getenv("RESTIC_TLS_CLIENT_CERT")
+	comp := os.Getenv("RESTIC_COMPRESSION")
+	if comp != "" {
+		// ignore error as there's no good way to handle it
+		_ = opts.Compression.Set(comp)
+	}
+	// parse target pack size from env, on error the default value will be used
+	targetPackSize, _ := strconv.ParseUint(os.Getenv("RESTIC_PACK_SIZE"), 10, 32)
+	opts.PackSize = uint(targetPackSize)
+
+	if os.Getenv("RESTIC_HTTP_USER_AGENT") != "" {
+		opts.HTTPUserAgent = os.Getenv("RESTIC_HTTP_USER_AGENT")
+	}
+}
+
+func (opts *GlobalOptions) PreRun(needsPassword bool) error {
+	// set verbosity, default is one
+	opts.verbosity = 1
+	if opts.Quiet && opts.Verbose > 0 {
+		return errors.Fatal("--quiet and --verbose cannot be specified at the same time")
+	}
+
+	switch {
+	case opts.Verbose >= 2:
+		opts.verbosity = 3
+	case opts.Verbose > 0:
+		opts.verbosity = 2
+	case opts.Quiet:
+		opts.verbosity = 0
+	}
+
+	// parse extended options
+	extendedOpts, err := options.Parse(opts.Options)
+	if err != nil {
+		return err
+	}
+	opts.extended = extendedOpts
+	if !needsPassword {
+		return nil
+	}
+	pwd, err := resolvePassword(opts, "RESTIC_PASSWORD")
+	if err != nil {
+		return errors.Fatal(fmt.Sprintf("Resolving password failed: %v\n", err))
+	}
+	opts.password = pwd
+	return nil
+}
+
 var globalOptions = GlobalOptions{
-	stdout: os.Stdout,
-	stderr: os.Stderr,
+	stdout:   os.Stdout,
+	stderr:   os.Stderr,
+	backends: collectBackends(),
 }
 
-func init() {
+func collectBackends() *location.Registry {
 	backends := location.NewRegistry()
 	backends.Register(azure.NewFactory())
 	backends.Register(b2.NewFactory())
@@ -111,59 +197,7 @@ func init() {
 	backends.Register(s3.NewFactory())
 	backends.Register(sftp.NewFactory())
 	backends.Register(swift.NewFactory())
-	globalOptions.backends = backends
-
-	f := cmdRoot.PersistentFlags()
-	f.StringVarP(&globalOptions.Repo, "repo", "r", "", "`repository` to backup to or restore from (default: $RESTIC_REPOSITORY)")
-	f.StringVarP(&globalOptions.RepositoryFile, "repository-file", "", "", "`file` to read the repository location from (default: $RESTIC_REPOSITORY_FILE)")
-	f.StringVarP(&globalOptions.PasswordFile, "password-file", "p", "", "`file` to read the repository password from (default: $RESTIC_PASSWORD_FILE)")
-	f.StringVarP(&globalOptions.KeyHint, "key-hint", "", "", "`key` ID of key to try decrypting first (default: $RESTIC_KEY_HINT)")
-	f.StringVarP(&globalOptions.PasswordCommand, "password-command", "", "", "shell `command` to obtain the repository password from (default: $RESTIC_PASSWORD_COMMAND)")
-	f.BoolVarP(&globalOptions.Quiet, "quiet", "q", false, "do not output comprehensive progress report")
-	// use empty parameter name as `-v, --verbose n` instead of the correct `--verbose=n` is confusing
-	f.CountVarP(&globalOptions.Verbose, "verbose", "v", "be verbose (specify multiple times or a level using --verbose=n``, max level/times is 2)")
-	f.BoolVar(&globalOptions.NoLock, "no-lock", false, "do not lock the repository, this allows some operations on read-only repositories")
-	f.DurationVar(&globalOptions.RetryLock, "retry-lock", 0, "retry to lock the repository if it is already locked, takes a value like 5m or 2h (default: no retries)")
-	f.BoolVarP(&globalOptions.JSON, "json", "", false, "set output mode to JSON for commands that support it")
-	f.StringVar(&globalOptions.CacheDir, "cache-dir", "", "set the cache `directory`. (default: use system default cache directory)")
-	f.BoolVar(&globalOptions.NoCache, "no-cache", false, "do not use a local cache")
-	f.StringSliceVar(&globalOptions.RootCertFilenames, "cacert", nil, "`file` to load root certificates from (default: use system certificates or $RESTIC_CACERT)")
-	f.StringVar(&globalOptions.TLSClientCertKeyFilename, "tls-client-cert", "", "path to a `file` containing PEM encoded TLS client certificate and private key (default: $RESTIC_TLS_CLIENT_CERT)")
-	f.BoolVar(&globalOptions.InsecureNoPassword, "insecure-no-password", false, "use an empty password for the repository, must be passed to every restic command (insecure)")
-	f.BoolVar(&globalOptions.InsecureTLS, "insecure-tls", false, "skip TLS certificate verification when connecting to the repository (insecure)")
-	f.BoolVar(&globalOptions.CleanupCache, "cleanup-cache", false, "auto remove old cache directories")
-	f.Var(&globalOptions.Compression, "compression", "compression mode (only available for repository format version 2), one of (auto|off|max) (default: $RESTIC_COMPRESSION)")
-	f.BoolVar(&globalOptions.NoExtraVerify, "no-extra-verify", false, "skip additional verification of data before upload (see documentation)")
-	f.IntVar(&globalOptions.Limits.UploadKb, "limit-upload", 0, "limits uploads to a maximum `rate` in KiB/s. (default: unlimited)")
-	f.IntVar(&globalOptions.Limits.DownloadKb, "limit-download", 0, "limits downloads to a maximum `rate` in KiB/s. (default: unlimited)")
-	f.UintVar(&globalOptions.PackSize, "pack-size", 0, "set target pack `size` in MiB, created pack files may be larger (default: $RESTIC_PACK_SIZE)")
-	f.StringSliceVarP(&globalOptions.Options, "option", "o", []string{}, "set extended option (`key=value`, can be specified multiple times)")
-	f.StringVar(&globalOptions.HTTPUserAgent, "http-user-agent", "", "set a http user agent for outgoing http requests")
-	f.DurationVar(&globalOptions.StuckRequestTimeout, "stuck-request-timeout", 5*time.Minute, "`duration` after which to retry stuck requests")
-	// Use our "generate" command instead of the cobra provided "completion" command
-	cmdRoot.CompletionOptions.DisableDefaultCmd = true
-
-	globalOptions.Repo = os.Getenv("RESTIC_REPOSITORY")
-	globalOptions.RepositoryFile = os.Getenv("RESTIC_REPOSITORY_FILE")
-	globalOptions.PasswordFile = os.Getenv("RESTIC_PASSWORD_FILE")
-	globalOptions.KeyHint = os.Getenv("RESTIC_KEY_HINT")
-	globalOptions.PasswordCommand = os.Getenv("RESTIC_PASSWORD_COMMAND")
-	if os.Getenv("RESTIC_CACERT") != "" {
-		globalOptions.RootCertFilenames = strings.Split(os.Getenv("RESTIC_CACERT"), ",")
-	}
-	globalOptions.TLSClientCertKeyFilename = os.Getenv("RESTIC_TLS_CLIENT_CERT")
-	comp := os.Getenv("RESTIC_COMPRESSION")
-	if comp != "" {
-		// ignore error as there's no good way to handle it
-		_ = globalOptions.Compression.Set(comp)
-	}
-	// parse target pack size from env, on error the default value will be used
-	targetPackSize, _ := strconv.ParseUint(os.Getenv("RESTIC_PACK_SIZE"), 10, 32)
-	globalOptions.PackSize = uint(targetPackSize)
-
-	if os.Getenv("RESTIC_HTTP_USER_AGENT") != "" {
-		globalOptions.HTTPUserAgent = os.Getenv("RESTIC_HTTP_USER_AGENT")
-	}
+	return backends
 }
 
 func stdinIsTerminal() bool {
@@ -254,7 +288,7 @@ func Warnf(format string, args ...interface{}) {
 }
 
 // resolvePassword determines the password to be used for opening the repository.
-func resolvePassword(opts GlobalOptions, envStr string) (string, error) {
+func resolvePassword(opts *GlobalOptions, envStr string) (string, error) {
 	if opts.PasswordFile != "" && opts.PasswordCommand != "" {
 		return "", errors.Fatalf("Password file and command are mutually exclusive options")
 	}
diff --git a/cmd/restic/global_debug.go b/cmd/restic/global_debug.go
index 502b2cf6e..1fe35146a 100644
--- a/cmd/restic/global_debug.go
+++ b/cmd/restic/global_debug.go
@@ -11,10 +11,44 @@ import (
 
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/repository"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 
 	"github.com/pkg/profile"
 )
 
+func registerProfiling(cmd *cobra.Command) {
+	var profiler profiler
+
+	origPreRun := cmd.PersistentPreRunE
+	cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
+		if origPreRun != nil {
+			if err := origPreRun(cmd, args); err != nil {
+				return err
+			}
+		}
+		return profiler.Start(profiler.opts)
+	}
+
+	origPostRun := cmd.PersistentPostRunE
+	cmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
+		profiler.Stop()
+		if origPostRun != nil {
+			return origPostRun(cmd, args)
+		}
+		return nil
+	}
+
+	profiler.opts.AddFlags(cmd.PersistentFlags())
+}
+
+type profiler struct {
+	opts ProfileOptions
+	stop interface {
+		Stop()
+	}
+}
+
 type ProfileOptions struct {
 	listen    string
 	memPath   string
@@ -24,19 +58,13 @@ type ProfileOptions struct {
 	insecure  bool
 }
 
-var profileOpts ProfileOptions
-var prof interface {
-	Stop()
-}
-
-func init() {
-	f := cmdRoot.PersistentFlags()
-	f.StringVar(&profileOpts.listen, "listen-profile", "", "listen on this `address:port` for memory profiling")
-	f.StringVar(&profileOpts.memPath, "mem-profile", "", "write memory profile to `dir`")
-	f.StringVar(&profileOpts.cpuPath, "cpu-profile", "", "write cpu profile to `dir`")
-	f.StringVar(&profileOpts.tracePath, "trace-profile", "", "write trace to `dir`")
-	f.StringVar(&profileOpts.blockPath, "block-profile", "", "write block profile to `dir`")
-	f.BoolVar(&profileOpts.insecure, "insecure-kdf", false, "use insecure KDF settings")
+func (opts *ProfileOptions) AddFlags(f *pflag.FlagSet) {
+	f.StringVar(&opts.listen, "listen-profile", "", "listen on this `address:port` for memory profiling")
+	f.StringVar(&opts.memPath, "mem-profile", "", "write memory profile to `dir`")
+	f.StringVar(&opts.cpuPath, "cpu-profile", "", "write cpu profile to `dir`")
+	f.StringVar(&opts.tracePath, "trace-profile", "", "write trace to `dir`")
+	f.StringVar(&opts.blockPath, "block-profile", "", "write block profile to `dir`")
+	f.BoolVar(&opts.insecure, "insecure-kdf", false, "use insecure KDF settings")
 }
 
 type fakeTestingTB struct{}
@@ -45,7 +73,7 @@ func (fakeTestingTB) Logf(msg string, args ...interface{}) {
 	fmt.Fprintf(os.Stderr, msg, args...)
 }
 
-func runDebug() error {
+func (p *profiler) Start(profileOpts ProfileOptions) error {
 	if profileOpts.listen != "" {
 		fmt.Fprintf(os.Stderr, "running profile HTTP server on %v\n", profileOpts.listen)
 		go func() {
@@ -75,13 +103,13 @@ func runDebug() error {
 	}
 
 	if profileOpts.memPath != "" {
-		prof = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.MemProfile, profile.ProfilePath(profileOpts.memPath))
+		p.stop = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.MemProfile, profile.ProfilePath(profileOpts.memPath))
 	} else if profileOpts.cpuPath != "" {
-		prof = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.CPUProfile, profile.ProfilePath(profileOpts.cpuPath))
+		p.stop = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.CPUProfile, profile.ProfilePath(profileOpts.cpuPath))
 	} else if profileOpts.tracePath != "" {
-		prof = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.TraceProfile, profile.ProfilePath(profileOpts.tracePath))
+		p.stop = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.TraceProfile, profile.ProfilePath(profileOpts.tracePath))
 	} else if profileOpts.blockPath != "" {
-		prof = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.BlockProfile, profile.ProfilePath(profileOpts.blockPath))
+		p.stop = profile.Start(profile.Quiet, profile.NoShutdownHook, profile.BlockProfile, profile.ProfilePath(profileOpts.blockPath))
 	}
 
 	if profileOpts.insecure {
@@ -91,8 +119,8 @@ func runDebug() error {
 	return nil
 }
 
-func stopDebug() {
-	if prof != nil {
-		prof.Stop()
+func (p *profiler) Stop() {
+	if p.stop != nil {
+		p.stop.Stop()
 	}
 }
diff --git a/cmd/restic/global_release.go b/cmd/restic/global_release.go
index 1dab5a293..2c4f28b13 100644
--- a/cmd/restic/global_release.go
+++ b/cmd/restic/global_release.go
@@ -3,8 +3,8 @@
 
 package main
 
-// runDebug is a noop without the debug tag.
-func runDebug() error { return nil }
+import "github.com/spf13/cobra"
 
-// stopDebug is a noop without the debug tag.
-func stopDebug() {}
+func registerProfiling(_ *cobra.Command) {
+	// No profiling in release mode
+}
diff --git a/cmd/restic/main.go b/cmd/restic/main.go
index 096c5695c..80da200e3 100644
--- a/cmd/restic/main.go
+++ b/cmd/restic/main.go
@@ -17,7 +17,6 @@ import (
 	"github.com/restic/restic/internal/debug"
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/feature"
-	"github.com/restic/restic/internal/options"
 	"github.com/restic/restic/internal/repository"
 	"github.com/restic/restic/internal/restic"
 )
@@ -29,66 +28,29 @@ func init() {
 
 var ErrOK = errors.New("ok")
 
-// cmdRoot is the base command when no other command has been specified.
-var cmdRoot = &cobra.Command{
-	Use:   "restic",
-	Short: "Backup and restore files",
-	Long: `
+var cmdGroupDefault = "default"
+var cmdGroupAdvanced = "advanced"
+
+func newRootCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "restic",
+		Short: "Backup and restore files",
+		Long: `
 restic is a backup program which allows saving multiple revisions of files and
 directories in an encrypted repository stored on different backends.
 
 The full documentation can be found at https://restic.readthedocs.io/ .
 `,
-	SilenceErrors:     true,
-	SilenceUsage:      true,
-	DisableAutoGenTag: true,
-
-	PersistentPreRunE: func(c *cobra.Command, _ []string) error {
-		// set verbosity, default is one
-		globalOptions.verbosity = 1
-		if globalOptions.Quiet && globalOptions.Verbose > 0 {
-			return errors.Fatal("--quiet and --verbose cannot be specified at the same time")
-		}
-
-		switch {
-		case globalOptions.Verbose >= 2:
-			globalOptions.verbosity = 3
-		case globalOptions.Verbose > 0:
-			globalOptions.verbosity = 2
-		case globalOptions.Quiet:
-			globalOptions.verbosity = 0
-		}
-
-		// parse extended options
-		opts, err := options.Parse(globalOptions.Options)
-		if err != nil {
-			return err
-		}
-		globalOptions.extended = opts
-		if !needsPassword(c.Name()) {
-			return nil
-		}
-		pwd, err := resolvePassword(globalOptions, "RESTIC_PASSWORD")
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Resolving password failed: %v\n", err)
-			Exit(1)
-		}
-		globalOptions.password = pwd
-
-		// run the debug functions for all subcommands (if build tag "debug" is
-		// enabled)
-		return runDebug()
-	},
-	PersistentPostRun: func(_ *cobra.Command, _ []string) {
-		stopDebug()
-	},
-}
+		SilenceErrors:     true,
+		SilenceUsage:      true,
+		DisableAutoGenTag: true,
 
-var cmdGroupDefault = "default"
-var cmdGroupAdvanced = "advanced"
+		PersistentPreRunE: func(c *cobra.Command, _ []string) error {
+			return globalOptions.PreRun(needsPassword(c.Name()))
+		},
+	}
 
-func init() {
-	cmdRoot.AddGroup(
+	cmd.AddGroup(
 		&cobra.Group{
 			ID:    cmdGroupDefault,
 			Title: "Available Commands:",
@@ -98,6 +60,49 @@ func init() {
 			Title: "Advanced Options:",
 		},
 	)
+
+	globalOptions.AddFlags(cmd.PersistentFlags())
+
+	// Use our "generate" command instead of the cobra provided "completion" command
+	cmd.CompletionOptions.DisableDefaultCmd = true
+
+	cmd.AddCommand(
+		newBackupCommand(),
+		newCacheCommand(),
+		newCatCommand(),
+		newCheckCommand(),
+		newCopyCommand(),
+		newDiffCommand(),
+		newDumpCommand(),
+		newFeaturesCommand(),
+		newFindCommand(),
+		newForgetCommand(),
+		newGenerateCommand(),
+		newInitCommand(),
+		newKeyCommand(),
+		newListCommand(),
+		newLsCommand(),
+		newMigrateCommand(),
+		newOptionsCommand(),
+		newPruneCommand(),
+		newRebuildIndexCommand(),
+		newRecoverCommand(),
+		newRepairCommand(),
+		newRestoreCommand(),
+		newRewriteCommand(),
+		newSnapshotsCommand(),
+		newStatsCommand(),
+		newTagCommand(),
+		newUnlockCommand(),
+		newVersionCommand(),
+	)
+
+	registerDebugCommand(cmd)
+	registerMountCommand(cmd)
+	registerSelfUpdateCommand(cmd)
+	registerProfiling(cmd)
+
+	return cmd
 }
 
 // Distinguish commands that need the password from those that work without,
@@ -164,7 +169,7 @@ func main() {
 		version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
 
 	ctx := createGlobalContext()
-	err = cmdRoot.ExecuteContext(ctx)
+	err = newRootCommand().ExecuteContext(ctx)
 
 	if err == nil {
 		err = ctx.Err()
diff --git a/cmd/restic/secondary_repo.go b/cmd/restic/secondary_repo.go
index 44621afa1..db4c93bad 100644
--- a/cmd/restic/secondary_repo.go
+++ b/cmd/restic/secondary_repo.go
@@ -25,7 +25,7 @@ type secondaryRepoOptions struct {
 	LegacyKeyHint         string
 }
 
-func initSecondaryRepoOptions(f *pflag.FlagSet, opts *secondaryRepoOptions, repoPrefix string, repoUsage string) {
+func (opts *secondaryRepoOptions) AddFlags(f *pflag.FlagSet, repoPrefix string, repoUsage string) {
 	f.StringVarP(&opts.LegacyRepo, "repo2", "", "", repoPrefix+" `repository` "+repoUsage+" (default: $RESTIC_REPOSITORY2)")
 	f.StringVarP(&opts.LegacyRepositoryFile, "repository-file2", "", "", "`file` from which to read the "+repoPrefix+" repository location "+repoUsage+" (default: $RESTIC_REPOSITORY_FILE2)")
 	f.StringVarP(&opts.LegacyPasswordFile, "password-file2", "", "", "`file` to read the "+repoPrefix+" repository password from (default: $RESTIC_PASSWORD_FILE2)")
@@ -110,7 +110,7 @@ func fillSecondaryGlobalOpts(ctx context.Context, opts secondaryRepoOptions, gop
 	if opts.password != "" {
 		dstGopts.password = opts.password
 	} else {
-		dstGopts.password, err = resolvePassword(dstGopts, pwdEnv)
+		dstGopts.password, err = resolvePassword(&dstGopts, pwdEnv)
 		if err != nil {
 			return GlobalOptions{}, false, err
 		}


Test patch:
diff --git a/cmd/restic/flags_test.go b/cmd/restic/flags_test.go
index 1ae20119c..b1001b7c5 100644
--- a/cmd/restic/flags_test.go
+++ b/cmd/restic/flags_test.go
@@ -8,7 +8,7 @@ import (
 // TestFlags checks for double defined flags, the commands will panic on
 // ParseFlags() when a shorthand flag is defined twice.
 func TestFlags(t *testing.T) {
-	for _, cmd := range cmdRoot.Commands() {
+	for _, cmd := range newRootCommand().Commands() {
 		t.Run(cmd.Name(), func(t *testing.T) {
 			cmd.Flags().SetOutput(io.Discard)
 			err := cmd.ParseFlags([]string{"--help"})