Repository: restic/restic
Pull request #4816 (2024-05-22 14:46:15)

backup: add support for `--skip-if-unchanged`
<!--
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?
-----------------------------------------------------
Implement the `--skip-if-unchanged` option as discussed in https://github.com/restic/restic/issues/662#issuecomment-1919737153 .
<!--
Describe the changes and their purpose here, as detailed as needed.
-->

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
Fixes https://github.com/restic/restic/issues/662
<!--
Link issues and relevant forum posts here.

If this PR resolves an issue on GitHub, use "Closes #1234" so that the issue
is closed automatically when this PR is merged.
-->

Checklist
---------

<!--
You do not need to check all the boxes below all at once. Feel free to take
your time and add more commits. If you're done and ready for review, please
check the last box. Enable a checkbox by replacing [ ] with [x].
-->

- [x] I have read the [contribution guidelines](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#providing-patches).
- [x] I have [enabled maintainer edits](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork).
- [x] I have added tests for all code changes.
- [x] I have added documentation for relevant changes (in the manual).
- [x] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).
- [x] I have run `gofmt` on the code in all commits.
- [x] All commit messages are formatted in the same style as [the other commits in the repo](https://github.com/restic/restic/blob/master/CONTRIBUTING.md#git-commits).
- [x] I'm done! This pull request is ready for review.



Patch:
diff --git a/changelog/unreleased/issue-662 b/changelog/unreleased/issue-662
new file mode 100644
index 000000000..e5a7c6fe8
--- /dev/null
+++ b/changelog/unreleased/issue-662
@@ -0,0 +1,11 @@
+Enhancement: `backup` can omit snapshot creation if there was no change
+
+The `backup` command always created a snapshot even if nothing changed
+compared to the parent snapshot.
+
+Restic now supports the `--skip-if-unchanged` option for the `backup`
+command to omit creating a snapshot if the new snapshot's content would
+be identical to that of the parent snapshot.
+
+https://github.com/restic/restic/issues/662
+https://github.com/restic/restic/pull/4816
diff --git a/cmd/restic/cmd_backup.go b/cmd/restic/cmd_backup.go
index e5369f7b9..434469683 100644
--- a/cmd/restic/cmd_backup.go
+++ b/cmd/restic/cmd_backup.go
@@ -87,6 +87,7 @@ type BackupOptions struct {
 	DryRun            bool
 	ReadConcurrency   uint
 	NoScan            bool
+	SkipIfUnchanged   bool
 }
 
 var backupOptions BackupOptions
@@ -133,6 +134,7 @@ func init() {
 	if runtime.GOOS == "windows" {
 		f.BoolVar(&backupOptions.UseFsSnapshot, "use-fs-snapshot", false, "use filesystem snapshot where possible (currently only Windows VSS)")
 	}
+	f.BoolVar(&backupOptions.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)
@@ -638,13 +640,14 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
 	}
 
 	snapshotOpts := archiver.SnapshotOptions{
-		Excludes:       opts.Excludes,
-		Tags:           opts.Tags.Flatten(),
-		BackupStart:    backupStart,
-		Time:           timeStamp,
-		Hostname:       opts.Host,
-		ParentSnapshot: parentSnapshot,
-		ProgramVersion: "restic " + version,
+		Excludes:        opts.Excludes,
+		Tags:            opts.Tags.Flatten(),
+		BackupStart:     backupStart,
+		Time:            timeStamp,
+		Hostname:        opts.Host,
+		ParentSnapshot:  parentSnapshot,
+		ProgramVersion:  "restic " + version,
+		SkipIfUnchanged: opts.SkipIfUnchanged,
 	}
 
 	if !gopts.JSON {
@@ -665,9 +668,6 @@ func runBackup(ctx context.Context, opts BackupOptions, gopts GlobalOptions, ter
 
 	// Report finished execution
 	progressReporter.Finish(id, summary, opts.DryRun)
-	if !gopts.JSON && !opts.DryRun {
-		progressPrinter.P("snapshot %s saved\n", id.Str())
-	}
 	if !success {
 		return ErrInvalidSourceData
 	}
diff --git a/cmd/restic/cmd_check.go b/cmd/restic/cmd_check.go
index 642a46c00..c4ae1c941 100644
--- a/cmd/restic/cmd_check.go
+++ b/cmd/restic/cmd_check.go
@@ -18,8 +18,6 @@ import (
 	"github.com/restic/restic/internal/repository"
 	"github.com/restic/restic/internal/restic"
 	"github.com/restic/restic/internal/ui"
-	"github.com/restic/restic/internal/ui/progress"
-	"github.com/restic/restic/internal/ui/termstatus"
 )
 
 var cmdCheck = &cobra.Command{
@@ -39,9 +37,7 @@ Exit status is 0 if the command was successful, and non-zero if there was any er
 `,
 	DisableAutoGenTag: true,
 	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runCheck(cmd.Context(), checkOptions, globalOptions, args, term)
+		return runCheck(cmd.Context(), checkOptions, globalOptions, args)
 	},
 	PreRunE: func(_ *cobra.Command, _ []string) error {
 		return checkFlags(checkOptions)
@@ -159,7 +155,7 @@ func parsePercentage(s string) (float64, error) {
 //   - if the user explicitly requested --no-cache, we don't use any cache
 //   - if the user provides --cache-dir, we use a cache in a temporary sub-directory of the specified directory and the sub-directory is deleted after the check
 //   - by default, we use a cache in a temporary directory that is deleted after the check
-func prepareCheckCache(opts CheckOptions, gopts *GlobalOptions, printer progress.Printer) (cleanup func()) {
+func prepareCheckCache(opts CheckOptions, gopts *GlobalOptions) (cleanup func()) {
 	cleanup = func() {}
 	if opts.WithCache {
 		// use the default cache, no setup needed
@@ -186,36 +182,34 @@ func prepareCheckCache(opts CheckOptions, gopts *GlobalOptions, printer progress
 	tempdir, err := os.MkdirTemp(cachedir, "restic-check-cache-")
 	if err != nil {
 		// if an error occurs, don't use any cache
-		printer.E("unable to create temporary directory for cache during check, disabling cache: %v\n", err)
+		Warnf("unable to create temporary directory for cache during check, disabling cache: %v\n", err)
 		gopts.NoCache = true
 		return cleanup
 	}
 
 	gopts.CacheDir = tempdir
-	printer.P("using temporary cache in %v\n", tempdir)
+	Verbosef("using temporary cache in %v\n", tempdir)
 
 	cleanup = func() {
 		err := fs.RemoveAll(tempdir)
 		if err != nil {
-			printer.E("error removing temporary cache directory: %v\n", err)
+			Warnf("error removing temporary cache directory: %v\n", err)
 		}
 	}
 
 	return cleanup
 }
 
-func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args []string, term *termstatus.Terminal) error {
+func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args []string) error {
 	if len(args) != 0 {
 		return errors.Fatal("the check command expects no arguments, only options - please see `restic help check` for usage and flags")
 	}
 
-	printer := newTerminalProgressPrinter(gopts.verbosity, term)
-
-	cleanup := prepareCheckCache(opts, &gopts, printer)
+	cleanup := prepareCheckCache(opts, &gopts)
 	defer cleanup()
 
 	if !gopts.NoLock {
-		printer.P("create exclusive lock for repository\n")
+		Verbosef("create exclusive lock for repository\n")
 	}
 	ctx, repo, unlock, err := openWithExclusiveLock(ctx, gopts, gopts.NoLock)
 	if err != nil {
@@ -229,8 +223,8 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 		return err
 	}
 
-	printer.P("load indexes\n")
-	bar := newIndexTerminalProgress(gopts.Quiet, gopts.JSON, term)
+	Verbosef("load indexes\n")
+	bar := newIndexProgress(gopts.Quiet, gopts.JSON)
 	hints, errs := chkr.LoadIndex(ctx, bar)
 	if ctx.Err() != nil {
 		return ctx.Err()
@@ -243,34 +237,34 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 	for _, hint := range hints {
 		switch hint.(type) {
 		case *checker.ErrDuplicatePacks:
-			term.Print(hint.Error())
+			Printf("%v\n", hint)
 			suggestIndexRebuild = true
 		case *checker.ErrOldIndexFormat:
-			printer.E("error: %v\n", hint)
+			Warnf("error: %v\n", hint)
 			suggestLegacyIndexRebuild = true
 			errorsFound = true
 		case *checker.ErrMixedPack:
-			term.Print(hint.Error())
+			Printf("%v\n", hint)
 			mixedFound = true
 		default:
-			printer.E("error: %v\n", hint)
+			Warnf("error: %v\n", hint)
 			errorsFound = true
 		}
 	}
 
 	if suggestIndexRebuild {
-		term.Print("Duplicate packs are non-critical, you can run `restic repair index' to correct this.\n")
+		Printf("Duplicate packs are non-critical, you can run `restic repair index' to correct this.\n")
 	}
 	if suggestLegacyIndexRebuild {
-		printer.E("Found indexes using the legacy format, you must run `restic repair index' to correct this.\n")
+		Warnf("Found indexes using the legacy format, you must run `restic repair index' to correct this.\n")
 	}
 	if mixedFound {
-		term.Print("Mixed packs with tree and data blobs are non-critical, you can run `restic prune` to correct this.\n")
+		Printf("Mixed packs with tree and data blobs are non-critical, you can run `restic prune` to correct this.\n")
 	}
 
 	if len(errs) > 0 {
 		for _, err := range errs {
-			printer.E("error: %v\n", err)
+			Warnf("error: %v\n", err)
 		}
 		return errors.Fatal("LoadIndex returned errors")
 	}
@@ -278,36 +272,36 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 	orphanedPacks := 0
 	errChan := make(chan error)
 
-	printer.P("check all packs\n")
+	Verbosef("check all packs\n")
 	go chkr.Packs(ctx, errChan)
 
 	for err := range errChan {
 		if checker.IsOrphanedPack(err) {
 			orphanedPacks++
-			printer.P("%v\n", err)
+			Verbosef("%v\n", err)
 		} else if err == checker.ErrLegacyLayout {
-			printer.P("repository still uses the S3 legacy layout\nPlease run `restic migrate s3legacy` to correct this.\n")
+			Verbosef("repository still uses the S3 legacy layout\nPlease run `restic migrate s3legacy` to correct this.\n")
 		} else {
 			errorsFound = true
-			printer.E("%v\n", err)
+			Warnf("%v\n", err)
 		}
 	}
 
 	if orphanedPacks > 0 {
-		printer.P("%d additional files were found in the repo, which likely contain duplicate data.\nThis is non-critical, you can run `restic prune` to correct this.\n", orphanedPacks)
+		Verbosef("%d additional files were found in the repo, which likely contain duplicate data.\nThis is non-critical, you can run `restic prune` to correct this.\n", orphanedPacks)
 	}
 	if ctx.Err() != nil {
 		return ctx.Err()
 	}
 
-	printer.P("check snapshots, trees and blobs\n")
+	Verbosef("check snapshots, trees and blobs\n")
 	errChan = make(chan error)
 	var wg sync.WaitGroup
 
 	wg.Add(1)
 	go func() {
 		defer wg.Done()
-		bar := newTerminalProgressMax(!gopts.Quiet, 0, "snapshots", term)
+		bar := newProgressMax(!gopts.Quiet, 0, "snapshots")
 		defer bar.Done()
 		chkr.Structure(ctx, bar, errChan)
 	}()
@@ -315,12 +309,16 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 	for err := range errChan {
 		errorsFound = true
 		if e, ok := err.(*checker.TreeError); ok {
-			printer.E("error for tree %v:\n", e.ID.Str())
+			var clean string
+			if stdoutCanUpdateStatus() {
+				clean = clearLine(0)
+			}
+			Warnf(clean+"error for tree %v:\n", e.ID.Str())
 			for _, treeErr := range e.Errors {
-				printer.E("  %v\n", treeErr)
+				Warnf("  %v\n", treeErr)
 			}
 		} else {
-			printer.E("error: %v\n", err)
+			Warnf("error: %v\n", err)
 		}
 	}
 
@@ -338,7 +336,7 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 			return err
 		}
 		for _, id := range unused {
-			printer.P("unused blob %v\n", id)
+			Verbosef("unused blob %v\n", id)
 			errorsFound = true
 		}
 	}
@@ -346,7 +344,7 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 	doReadData := func(packs map[restic.ID]int64) {
 		packCount := uint64(len(packs))
 
-		p := newTerminalProgressMax(!gopts.Quiet, packCount, "packs", term)
+		p := newProgressMax(!gopts.Quiet, packCount, "packs")
 		errChan := make(chan error)
 
 		go chkr.ReadPacks(ctx, packs, p, errChan)
@@ -355,7 +353,7 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 
 		for err := range errChan {
 			errorsFound = true
-			printer.E("%v\n", err)
+			Warnf("%v\n", err)
 			if err, ok := err.(*repository.ErrPackData); ok {
 				salvagePacks = append(salvagePacks, err.PackID)
 			}
@@ -363,19 +361,19 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 		p.Done()
 
 		if len(salvagePacks) > 0 {
-			printer.E("\nThe repository contains pack files with damaged blobs. These blobs must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.\n\n")
+			Warnf("\nThe repository contains pack files with damaged blobs. These blobs must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.\n\n")
 			var strIDs []string
 			for _, id := range salvagePacks {
 				strIDs = append(strIDs, id.String())
 			}
-			printer.E("restic repair packs %v\nrestic repair snapshots --forget\n\n", strings.Join(strIDs, " "))
-			printer.E("Corrupted blobs are either caused by hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!\n")
+			Warnf("restic repair packs %v\nrestic repair snapshots --forget\n\n", strings.Join(strIDs, " "))
+			Warnf("Corrupted blobs are either caused by hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!\n")
 		}
 	}
 
 	switch {
 	case opts.ReadData:
-		printer.P("read all data\n")
+		Verbosef("read all data\n")
 		doReadData(selectPacksByBucket(chkr.GetPacks(), 1, 1))
 	case opts.ReadDataSubset != "":
 		var packs map[restic.ID]int64
@@ -385,12 +383,12 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 			totalBuckets := dataSubset[1]
 			packs = selectPacksByBucket(chkr.GetPacks(), bucket, totalBuckets)
 			packCount := uint64(len(packs))
-			printer.P("read group #%d of %d data packs (out of total %d packs in %d groups)\n", bucket, packCount, chkr.CountPacks(), totalBuckets)
+			Verbosef("read group #%d of %d data packs (out of total %d packs in %d groups)\n", bucket, packCount, chkr.CountPacks(), totalBuckets)
 		} else if strings.HasSuffix(opts.ReadDataSubset, "%") {
 			percentage, err := parsePercentage(opts.ReadDataSubset)
 			if err == nil {
 				packs = selectRandomPacksByPercentage(chkr.GetPacks(), percentage)
-				printer.P("read %.1f%% of data packs\n", percentage)
+				Verbosef("read %.1f%% of data packs\n", percentage)
 			}
 		} else {
 			repoSize := int64(0)
@@ -406,7 +404,7 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 				subsetSize = repoSize
 			}
 			packs = selectRandomPacksByFileSize(chkr.GetPacks(), subsetSize, repoSize)
-			printer.P("read %d bytes of data packs\n", subsetSize)
+			Verbosef("read %d bytes of data packs\n", subsetSize)
 		}
 		if packs == nil {
 			return errors.Fatal("internal error: failed to select packs to check")
@@ -421,7 +419,7 @@ func runCheck(ctx context.Context, opts CheckOptions, gopts GlobalOptions, args
 	if errorsFound {
 		return errors.Fatal("repository contains errors")
 	}
-	printer.P("no errors were found\n")
+	Verbosef("no errors were found\n")
 
 	return nil
 }
diff --git a/cmd/restic/cmd_migrate.go b/cmd/restic/cmd_migrate.go
index 0f9bdde1a..c3f82b8dd 100644
--- a/cmd/restic/cmd_migrate.go
+++ b/cmd/restic/cmd_migrate.go
@@ -5,8 +5,6 @@ import (
 
 	"github.com/restic/restic/internal/migrations"
 	"github.com/restic/restic/internal/restic"
-	"github.com/restic/restic/internal/ui/progress"
-	"github.com/restic/restic/internal/ui/termstatus"
 
 	"github.com/spf13/cobra"
 )
@@ -26,9 +24,7 @@ Exit status is 0 if the command was successful, and non-zero if there was any er
 `,
 	DisableAutoGenTag: true,
 	RunE: func(cmd *cobra.Command, args []string) error {
-		term, cancel := setupTermstatus()
-		defer cancel()
-		return runMigrate(cmd.Context(), migrateOptions, globalOptions, args, term)
+		return runMigrate(cmd.Context(), migrateOptions, globalOptions, args)
 	},
 }
 
@@ -45,8 +41,8 @@ func init() {
 	f.BoolVarP(&migrateOptions.Force, "force", "f", false, `apply a migration a second time`)
 }
 
-func checkMigrations(ctx context.Context, repo restic.Repository, printer progress.Printer) error {
-	printer.P("available migrations:\n")
+func checkMigrations(ctx context.Context, repo restic.Repository) error {
+	Printf("available migrations:\n")
 	found := false
 
 	for _, m := range migrations.All {
@@ -56,19 +52,19 @@ func checkMigrations(ctx context.Context, repo restic.Repository, printer progre
 		}
 
 		if ok {
-			printer.P("  %v\t%v\n", m.Name(), m.Desc())
+			Printf("  %v\t%v\n", m.Name(), m.Desc())
 			found = true
 		}
 	}
 
 	if !found {
-		printer.P("no migrations found\n")
+		Printf("no migrations found\n")
 	}
 
 	return nil
 }
 
-func applyMigrations(ctx context.Context, opts MigrateOptions, gopts GlobalOptions, repo restic.Repository, args []string, term *termstatus.Terminal, printer progress.Printer) error {
+func applyMigrations(ctx context.Context, opts MigrateOptions, gopts GlobalOptions, repo restic.Repository, args []string) error {
 	var firsterr error
 	for _, name := range args {
 		for _, m := range migrations.All {
@@ -83,37 +79,36 @@ func applyMigrations(ctx context.Context, opts MigrateOptions, gopts GlobalOptio
 						if reason == "" {
 							reason = "check failed"
 						}
-						printer.E("migration %v cannot be applied: %v\nIf you want to apply this migration anyway, re-run with option --force\n", m.Name(), reason)
+						Warnf("migration %v cannot be applied: %v\nIf you want to apply this migration anyway, re-run with option --force\n", m.Name(), reason)
 						continue
 					}
 
-					printer.E("check for migration %v failed, continuing anyway\n", m.Name())
+					Warnf("check for migration %v failed, continuing anyway\n", m.Name())
 				}
 
 				if m.RepoCheck() {
-					printer.P("checking repository integrity...\n")
+					Printf("checking repository integrity...\n")
 
 					checkOptions := CheckOptions{}
 					checkGopts := gopts
 					// the repository is already locked
 					checkGopts.NoLock = true
-
-					err = runCheck(ctx, checkOptions, checkGopts, []string{}, term)
+					err = runCheck(ctx, checkOptions, checkGopts, []string{})
 					if err != nil {
 						return err
 					}
 				}
 
-				printer.P("applying migration %v...\n", m.Name())
+				Printf("applying migration %v...\n", m.Name())
 				if err = m.Apply(ctx, repo); err != nil {
-					printer.E("migration %v failed: %v\n", m.Name(), err)
+					Warnf("migration %v failed: %v\n", m.Name(), err)
 					if firsterr == nil {
 						firsterr = err
 					}
 					continue
 				}
 
-				printer.P("migration %v: success\n", m.Name())
+				Printf("migration %v: success\n", m.Name())
 			}
 		}
 	}
@@ -121,9 +116,7 @@ func applyMigrations(ctx context.Context, opts MigrateOptions, gopts GlobalOptio
 	return firsterr
 }
 
-func runMigrate(ctx context.Context, opts MigrateOptions, gopts GlobalOptions, args []string, term *termstatus.Terminal) error {
-	printer := newTerminalProgressPrinter(gopts.verbosity, term)
-
+func runMigrate(ctx context.Context, opts MigrateOptions, gopts GlobalOptions, args []string) error {
 	ctx, repo, unlock, err := openWithExclusiveLock(ctx, gopts, false)
 	if err != nil {
 		return err
@@ -131,8 +124,8 @@ func runMigrate(ctx context.Context, opts MigrateOptions, gopts GlobalOptions, a
 	defer unlock()
 
 	if len(args) == 0 {
-		return checkMigrations(ctx, repo, printer)
+		return checkMigrations(ctx, repo)
 	}
 
-	return applyMigrations(ctx, opts, gopts, repo, args, term, printer)
+	return applyMigrations(ctx, opts, gopts, repo, args)
 }
diff --git a/doc/040_backup.rst b/doc/040_backup.rst
index efaa73255..a07413ad9 100644
--- a/doc/040_backup.rst
+++ b/doc/040_backup.rst
@@ -24,16 +24,17 @@ again:
     $ restic -r /srv/restic-repo --verbose backup ~/work
     open repository
     enter password for repository:
-    password is correct
-    lock repository
+    repository a14e5863 opened (version 2, compression level auto)
     load index files
-    start scan
-    start backup
-    scan finished in 1.837s
-    processed 1.720 GiB in 0:12
+    start scan on [/home/user/work]
+    start backup on [/home/user/work]
+    scan finished in 1.837s: 5307 files, 1.720 GiB
+    
     Files:        5307 new,     0 changed,     0 unmodified
     Dirs:         1867 new,     0 changed,     0 unmodified
-    Added:      1.200 GiB
+    Added to the repository: 1.200 GiB (1.103 GiB stored)
+    
+    processed 5307 files, 1.720 GiB in 0:12
     snapshot 40dc1520 saved
 
 As you can see, restic created a backup of the directory and was pretty
@@ -44,6 +45,7 @@ You can see that restic tells us it processed 1.720 GiB of data, this is the
 size of the files and directories in ``~/work`` on the local file system. It
 also tells us that only 1.200 GiB was added to the repository. This means that
 some of the data was duplicate and restic was able to efficiently reduce it.
+The data compression also managed to compress the data down to 1.103 GiB.
 
 If you don't pass the ``--verbose`` option, restic will print less data. You'll
 still get a nice live status display. Be aware that the live status shows the
@@ -109,17 +111,18 @@ repository (since all data is already there). This is de-duplication at work!
     $ restic -r /srv/restic-repo --verbose backup ~/work
     open repository
     enter password for repository:
-    password is correct
-    lock repository
+    repository a14e5863 opened (version 2, compression level auto)
     load index files
-    using parent snapshot d875ae93
-    start scan
-    start backup
-    scan finished in 1.881s
-    processed 1.720 GiB in 0:03
+    using parent snapshot 40dc1520
+    start scan on [/home/user/work]
+    start backup on [/home/user/work]
+    scan finished in 1.881s: 5307 files, 1.720 GiB
+    
     Files:           0 new,     0 changed,  5307 unmodified
     Dirs:            0 new,     0 changed,  1867 unmodified
-    Added:      0 B
+    Added to the repository: 0 B   (0 B   stored)
+
+    processed 5307 files, 1.720 GiB in 0:03
     snapshot 79766175 saved
 
 You can even backup individual files in the same repository (not passing
@@ -129,7 +132,6 @@ You can even backup individual files in the same repository (not passing
 
     $ restic -r /srv/restic-repo backup ~/work.txt
     enter password for repository:
-    password is correct
     snapshot 249d0210 saved
 
 If you're interested in what restic does, pass ``--verbose`` twice (or
@@ -143,7 +145,6 @@ restic encounters:
     $ restic -r /srv/restic-repo --verbose --verbose backup ~/work.txt
     open repository
     enter password for repository:
-    password is correct
     lock repository
     load index files
     using parent snapshot f3f8d56b
@@ -231,6 +232,40 @@ On **Windows**, a file is considered unchanged when its path, size
 and modification time match, and only ``--force`` has any effect.
 The other options are recognized but ignored.
 
+Skip creating snapshots if unchanged
+************************************
+
+By default, restic always creates a new snapshot even if nothing has changed
+compared to the parent snapshot. To omit the creation of a new snapshot in this
+case, specify the ``--skip-if-unchanged`` option.
+
+Note that when using absolute paths to specify the backup target, then also
+changes to the parent folders result in a changed snapshot. For example, a backup
+of ``/home/user/work`` will create a new snapshot if the metadata of either
+ ``/``, ``/home`` or ``/home/user`` change. To avoid this problem run restic from
+the corresponding folder and use relative paths.
+
+.. code-block:: console
+
+    $ cd /home/user/work && restic -r /srv/restic-repo backup . --skip-if-unchanged
+
+    open repository
+    enter password for repository:
+    repository a14e5863 opened (version 2, compression level auto)
+    load index files
+    using parent snapshot 40dc1520
+    start scan on [.]
+    start backup on [.]
+    scan finished in 1.814s: 5307 files, 1.720 GiB
+    
+    Files:           0 new,     0 changed,  5307 unmodified
+    Dirs:            0 new,     0 changed,  1867 unmodified
+    Added to the repository: 0 B   (0 B   stored)
+
+    processed 5307 files, 1.720 GiB in 0:03
+    skipped creating snapshot
+
+
 Dry Runs
 ********
 
@@ -469,7 +504,6 @@ and displays a small statistic, just pass the command two snapshot IDs:
 .. code-block:: console
 
     $ restic -r /srv/restic-repo diff 5845b002 2ab627a6
-    password is correct
     comparing snapshot ea657ce5 to 2ab627a6:
 
      C   /restic/cmd_diff.go
diff --git a/doc/045_working_with_repos.rst b/doc/045_working_with_repos.rst
index 85c022580..9d6167895 100644
--- a/doc/045_working_with_repos.rst
+++ b/doc/045_working_with_repos.rst
@@ -163,8 +163,8 @@ example from a local to a remote repository, you can use the ``copy`` command:
 .. code-block:: console
 
     $ restic -r /srv/restic-repo-copy copy --from-repo /srv/restic-repo
-    repository d6504c63 opened successfully, password is correct
-    repository 3dd0878c opened successfully, password is correct
+    repository d6504c63 opened successfully
+    repository 3dd0878c opened successfully
 
     snapshot 410b18a2 of [/home/user/work] at 2020-06-09 23:15:57.305305 +0200 CEST by user@kasimir
       copy started, this may take a while...
@@ -263,7 +263,7 @@ the unwanted files from affected snapshots by rewriting them using the
 .. code-block:: console
 
     $ restic -r /srv/restic-repo rewrite --exclude secret-file
-    repository c881945a opened (repository version 2) successfully, password is correct
+    repository c881945a opened (repository version 2) successfully
 
     snapshot 6160ddb2 of [/home/user/work] at 2022-06-12 16:01:28.406630608 +0200 CEST by user@kasimir
     excluding /home/user/work/secret-file
@@ -274,7 +274,7 @@ the unwanted files from affected snapshots by rewriting them using the
     modified 1 snapshots
 
     $ restic -r /srv/restic-repo rewrite --exclude secret-file 6160ddb2
-    repository c881945a opened (repository version 2) successfully, password is correct
+    repository c881945a opened (repository version 2) successfully
 
     snapshot 6160ddb2 of [/home/user/work] at 2022-06-12 16:01:28.406630608 +0200 CEST by user@kasimir
     excluding /home/user/work/secret-file
diff --git a/doc/060_forget.rst b/doc/060_forget.rst
index b8d206c97..fe0236f12 100644
--- a/doc/060_forget.rst
+++ b/doc/060_forget.rst
@@ -80,7 +80,7 @@ command must be run:
 
     $ restic -r /srv/restic-repo prune
     enter password for repository:
-    repository 33002c5e opened successfully, password is correct
+    repository 33002c5e opened successfully
     loading all snapshots...
     loading indexes...
     finding data that is still in use for 4 snapshots
@@ -265,7 +265,7 @@ Sunday for 12 weeks:
 .. code-block:: console
 
    $ restic snapshots
-   repository f00c6e2a opened successfully, password is correct
+   repository f00c6e2a opened successfully
    ID        Time                 Host        Tags        Paths
    ---------------------------------------------------------------
    0a1f9759  2019-09-01 11:00:00  mopped                  /home/user/work
@@ -289,7 +289,7 @@ four Sundays, and remove the other snapshots:
 .. code-block:: console
 
    $ restic forget --keep-daily 4 --dry-run
-   repository f00c6e2a opened successfully, password is correct
+   repository f00c6e2a opened successfully
    Applying Policy: keep the last 4 daily snapshots
    keep 4 snapshots:
    ID        Time                 Host        Tags        Reasons         Paths
diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst
index 28419c292..e413e349f 100644
--- a/doc/075_scripting.rst
+++ b/doc/075_scripting.rst
@@ -173,7 +173,8 @@ Summary is the last output line in a successful backup.
 +---------------------------+---------------------------------------------------------+
 | ``total_duration``        | Total time it took for the operation to complete        |
 +---------------------------+---------------------------------------------------------+
-| ``snapshot_id``           | ID of the new snapshot                                  |
+| ``snapshot_id``           | ID of the new snapshot. Field is omitted if snapshot    |
+|                           | creation was skipped                                    |
 +---------------------------+---------------------------------------------------------+
 
 
diff --git a/doc/manual_rest.rst b/doc/manual_rest.rst
index 188ebcb70..3f8b3a2c7 100644
--- a/doc/manual_rest.rst
+++ b/doc/manual_rest.rst
@@ -329,7 +329,6 @@ required to restore the latest snapshot (from any host that made it):
 .. code-block:: console
 
     $ restic stats latest
-    password is correct
     Total File Count:   10538
           Total Size:   37.824 GiB
 
@@ -340,7 +339,6 @@ host by using the ``--host`` flag:
 .. code-block:: console
 
     $ restic stats --host myserver latest
-    password is correct
     Total File Count:   21766
           Total Size:   481.783 GiB
 
@@ -357,7 +355,6 @@ has restic's deduplication helped? We can check:
 .. code-block:: console
 
     $ restic stats --host myserver --mode raw-data latest
-    password is correct
     Total Blob Count:   340847
           Total Size:   458.663 GiB
 
diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go
index 86b329a9a..9a31911b9 100644
--- a/internal/archiver/archiver.go
+++ b/internal/archiver/archiver.go
@@ -767,6 +767,8 @@ type SnapshotOptions struct {
 	Time           time.Time
 	ParentSnapshot *restic.Snapshot
 	ProgramVersion string
+	// SkipIfUnchanged omits the snapshot creation if it is identical to the parent snapshot.
+	SkipIfUnchanged bool
 }
 
 // loadParentTree loads a tree referenced by snapshot id. If id is null, nil is returned.
@@ -880,6 +882,13 @@ func (arch *Archiver) Snapshot(ctx context.Context, targets []string, opts Snaps
 		return nil, restic.ID{}, nil, err
 	}
 
+	if opts.ParentSnapshot != nil && opts.SkipIfUnchanged {
+		ps := opts.ParentSnapshot
+		if ps.Tree != nil && rootTreeID.Equal(*ps.Tree) {
+			return nil, restic.ID{}, arch.summary, nil
+		}
+	}
+
 	sn, err := restic.NewSnapshot(targets, opts.Tags, opts.Hostname, opts.Time)
 	if err != nil {
 		return nil, restic.ID{}, nil, err
diff --git a/internal/ui/backup/json.go b/internal/ui/backup/json.go
index a14c7ccec..64b5de13b 100644
--- a/internal/ui/backup/json.go
+++ b/internal/ui/backup/json.go
@@ -164,6 +164,11 @@ func (b *JSONProgress) ReportTotal(start time.Time, s archiver.ScanStats) {
 
 // Finish prints the finishing messages.
 func (b *JSONProgress) Finish(snapshotID restic.ID, start time.Time, summary *archiver.Summary, dryRun bool) {
+	id := ""
+	// empty if snapshot creation was skipped
+	if !snapshotID.IsNull() {
+		id = snapshotID.String()
+	}
 	b.print(summaryOutput{
 		MessageType:         "summary",
 		FilesNew:            summary.Files.New,
@@ -179,7 +184,7 @@ func (b *JSONProgress) Finish(snapshotID restic.ID, start time.Time, summary *ar
 		TotalFilesProcessed: summary.Files.New + summary.Files.Changed + summary.Files.Unchanged,
 		TotalBytesProcessed: summary.ProcessedBytes,
 		TotalDuration:       time.Since(start).Seconds(),
-		SnapshotID:          snapshotID.String(),
+		SnapshotID:          id,
 		DryRun:              dryRun,
 	})
 }
@@ -235,6 +240,6 @@ type summaryOutput struct {
 	TotalFilesProcessed uint    `json:"total_files_processed"`
 	TotalBytesProcessed uint64  `json:"total_bytes_processed"`
 	TotalDuration       float64 `json:"total_duration"` // in seconds
-	SnapshotID          string  `json:"snapshot_id"`
+	SnapshotID          string  `json:"snapshot_id,omitempty"`
 	DryRun              bool    `json:"dry_run,omitempty"`
 }
diff --git a/internal/ui/backup/text.go b/internal/ui/backup/text.go
index 00d025e51..43e963b82 100644
--- a/internal/ui/backup/text.go
+++ b/internal/ui/backup/text.go
@@ -126,7 +126,7 @@ func (b *TextProgress) Reset() {
 }
 
 // Finish prints the finishing messages.
-func (b *TextProgress) Finish(_ restic.ID, start time.Time, summary *archiver.Summary, dryRun bool) {
+func (b *TextProgress) Finish(id restic.ID, start time.Time, summary *archiver.Summary, dryRun bool) {
 	b.P("\n")
 	b.P("Files:       %5d new, %5d changed, %5d unmodified\n", summary.Files.New, summary.Files.Changed, summary.Files.Unchanged)
 	b.P("Dirs:        %5d new, %5d changed, %5d unmodified\n", summary.Dirs.New, summary.Dirs.Changed, summary.Dirs.Unchanged)
@@ -145,4 +145,12 @@ func (b *TextProgress) Finish(_ restic.ID, start time.Time, summary *archiver.Su
 		ui.FormatBytes(summary.ProcessedBytes),
 		ui.FormatDuration(time.Since(start)),
 	)
+
+	if !dryRun {
+		if id.IsNull() {
+			b.P("skipped creating snapshot\n")
+		} else {
+			b.P("snapshot %s saved\n", id.Str())
+		}
+	}
 }


Test patch:
diff --git a/cmd/restic/cmd_backup_integration_test.go b/cmd/restic/cmd_backup_integration_test.go
index f7372851f..5e00b84b0 100644
--- a/cmd/restic/cmd_backup_integration_test.go
+++ b/cmd/restic/cmd_backup_integration_test.go
@@ -641,3 +641,18 @@ func TestBackupEmptyPassword(t *testing.T) {
 	testListSnapshots(t, env.gopts, 1)
 	testRunCheck(t, env.gopts)
 }
+
+func TestBackupSkipIfUnchanged(t *testing.T) {
+	env, cleanup := withTestEnvironment(t)
+	defer cleanup()
+
+	testSetupBackupData(t, env)
+	opts := BackupOptions{SkipIfUnchanged: true}
+
+	for i := 0; i < 3; i++ {
+		testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, opts, env.gopts)
+		testListSnapshots(t, env.gopts, 1)
+	}
+
+	testRunCheck(t, env.gopts)
+}
diff --git a/cmd/restic/cmd_check_integration_test.go b/cmd/restic/cmd_check_integration_test.go
index f1e6517e0..9eb4fec62 100644
--- a/cmd/restic/cmd_check_integration_test.go
+++ b/cmd/restic/cmd_check_integration_test.go
@@ -1,12 +1,10 @@
 package main
 
 import (
-	"bytes"
 	"context"
 	"testing"
 
 	rtest "github.com/restic/restic/internal/test"
-	"github.com/restic/restic/internal/ui/termstatus"
 )
 
 func testRunCheck(t testing.TB, gopts GlobalOptions) {
@@ -25,14 +23,12 @@ func testRunCheckMustFail(t testing.TB, gopts GlobalOptions) {
 }
 
 func testRunCheckOutput(gopts GlobalOptions, checkUnused bool) (string, error) {
-	buf := bytes.NewBuffer(nil)
-	gopts.stdout = buf
-	err := withTermStatus(gopts, func(ctx context.Context, term *termstatus.Terminal) error {
+	buf, err := withCaptureStdout(func() error {
 		opts := CheckOptions{
 			ReadData:    true,
 			CheckUnused: checkUnused,
 		}
-		return runCheck(context.TODO(), opts, gopts, nil, term)
+		return runCheck(context.TODO(), opts, gopts, nil)
 	})
 	return buf.String(), err
 }
diff --git a/cmd/restic/cmd_check_test.go b/cmd/restic/cmd_check_test.go
index 9516ce420..15003ee48 100644
--- a/cmd/restic/cmd_check_test.go
+++ b/cmd/restic/cmd_check_test.go
@@ -10,7 +10,6 @@ import (
 
 	"github.com/restic/restic/internal/restic"
 	rtest "github.com/restic/restic/internal/test"
-	"github.com/restic/restic/internal/ui/progress"
 )
 
 func TestParsePercentage(t *testing.T) {
@@ -202,7 +201,7 @@ func TestPrepareCheckCache(t *testing.T) {
 				rtest.OK(t, err)
 			}
 			gopts := GlobalOptions{CacheDir: tmpDirBase}
-			cleanup := prepareCheckCache(testCase.opts, &gopts, &progress.NoopPrinter{})
+			cleanup := prepareCheckCache(testCase.opts, &gopts)
 			files, err := os.ReadDir(tmpDirBase)
 			rtest.OK(t, err)
 
diff --git a/cmd/restic/cmd_prune_integration_test.go b/cmd/restic/cmd_prune_integration_test.go
index 19c430716..5eb16a2ea 100644
--- a/cmd/restic/cmd_prune_integration_test.go
+++ b/cmd/restic/cmd_prune_integration_test.go
@@ -111,9 +111,7 @@ func testPrune(t *testing.T, pruneOpts PruneOptions, checkOpts CheckOptions) {
 
 	createPrunableRepo(t, env)
 	testRunPrune(t, env.gopts, pruneOpts)
-	rtest.OK(t, withTermStatus(env.gopts, func(ctx context.Context, term *termstatus.Terminal) error {
-		return runCheck(context.TODO(), checkOpts, env.gopts, nil, term)
-	}))
+	rtest.OK(t, runCheck(context.TODO(), checkOpts, env.gopts, nil))
 }
 
 var pruneDefaultOptions = PruneOptions{MaxUnused: "5%"}
@@ -220,9 +218,7 @@ func testEdgeCaseRepo(t *testing.T, tarfile string, optionsCheck CheckOptions, o
 	if checkOK {
 		testRunCheck(t, env.gopts)
 	} else {
-		rtest.Assert(t, withTermStatus(env.gopts, func(ctx context.Context, term *termstatus.Terminal) error {
-			return runCheck(context.TODO(), optionsCheck, env.gopts, nil, term)
-		}) != nil,
+		rtest.Assert(t, runCheck(context.TODO(), optionsCheck, env.gopts, nil) != nil,
 			"check should have reported an error")
 	}
 
diff --git a/cmd/restic/integration_test.go b/cmd/restic/integration_test.go
index 4cecec6bc..a7b66add8 100644
--- a/cmd/restic/integration_test.go
+++ b/cmd/restic/integration_test.go
@@ -87,9 +87,8 @@ func TestListOnce(t *testing.T) {
 
 	createPrunableRepo(t, env)
 	testRunPrune(t, env.gopts, pruneOpts)
-	rtest.OK(t, withTermStatus(env.gopts, func(ctx context.Context, term *termstatus.Terminal) error {
-		return runCheck(context.TODO(), checkOpts, env.gopts, nil, term)
-	}))
+	rtest.OK(t, runCheck(context.TODO(), checkOpts, env.gopts, nil))
+
 	rtest.OK(t, withTermStatus(env.gopts, func(ctx context.Context, term *termstatus.Terminal) error {
 		return runRebuildIndex(context.TODO(), RepairIndexOptions{}, env.gopts, term)
 	}))