diff --git a/changelog/unreleased/pull-4839 b/changelog/unreleased/pull-4839 new file mode 100644 index 000000000..8d644407b --- /dev/null +++ b/changelog/unreleased/pull-4839 @@ -0,0 +1,7 @@ +Enhancement: Add dry-run support to `restore` command + +The `restore` command now supports the `--dry-run` option to perform +a dry run. Pass the `--verbose=2` option to see which files would +remain unchanged, which would be updated or freshly restored. + +https://github.com/restic/restic/pull/4839 diff --git a/cmd/restic/cmd_restore.go b/cmd/restic/cmd_restore.go index 467a7535a..aea6457bd 100644 --- a/cmd/restic/cmd_restore.go +++ b/cmd/restic/cmd_restore.go @@ -47,6 +47,7 @@ type RestoreOptions struct { includePatternOptions Target string restic.SnapshotFilter + DryRun bool Sparse bool Verify bool Overwrite restorer.OverwriteBehavior @@ -64,6 +65,7 @@ func init() { initIncludePatternOptions(flags, &restoreOptions.includePatternOptions) 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)") @@ -99,6 +101,9 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions, if hasExcludes && hasIncludes { return errors.Fatal("exclude and include patterns are mutually exclusive") } + if opts.DryRun && opts.Verify { + return errors.Fatal("--dry-run and --verify are mutually exclusive") + } snapshotIDString := args[0] @@ -133,13 +138,14 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions, msg := ui.NewMessage(term, gopts.verbosity) var printer restoreui.ProgressPrinter if gopts.JSON { - printer = restoreui.NewJSONProgress(term) + printer = restoreui.NewJSONProgress(term, gopts.verbosity) } else { - printer = restoreui.NewTextProgress(term) + printer = restoreui.NewTextProgress(term, gopts.verbosity) } progress := restoreui.NewProgress(printer, calculateProgressInterval(!gopts.Quiet, gopts.JSON)) res := restorer.NewRestorer(repo, sn, restorer.Options{ + DryRun: opts.DryRun, Sparse: opts.Sparse, Progress: progress, Overwrite: opts.Overwrite, diff --git a/doc/040_backup.rst b/doc/040_backup.rst index a07413ad9..2aeb063d9 100644 --- a/doc/040_backup.rst +++ b/doc/040_backup.rst @@ -242,7 +242,7 @@ 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 +``/``, ``/home`` or ``/home/user`` change. To avoid this problem run restic from the corresponding folder and use relative paths. .. code-block:: console diff --git a/doc/050_restore.rst b/doc/050_restore.rst index 9c24f09de..497488241 100644 --- a/doc/050_restore.rst +++ b/doc/050_restore.rst @@ -69,7 +69,7 @@ There are case insensitive variants of ``--exclude`` and ``--include`` called ignore the casing of paths. There are also ``--include-file``, ``--exclude-file``, ``--iinclude-file`` and - ``--iexclude-file`` flags that read the include and exclude patterns from a file. +``--iexclude-file`` flags that read the include and exclude patterns from a file. Restoring symbolic links on windows is only possible when the user has ``SeCreateSymbolicLinkPrivilege`` privilege or is running as admin. This is a @@ -111,6 +111,28 @@ values are supported: newer modification time (mtime). * ``--overwrite never``: never overwrite existing files. +Dry run +------- + +As restore operations can take a long time, it can be useful to perform a dry-run to +see what would be restored without having to run the full restore operation. The +restore command supports the ``--dry-run`` option and prints information about the +restored files when specifying ``--verbose=2``. + +.. code-block:: console + + $ restic restore --target /tmp/restore-work --dry-run --verbose=2 latest + + unchanged /restic/internal/walker/walker.go with size 2.812 KiB + updated /restic/internal/walker/walker_test.go with size 11.143 KiB + restored /restic/restic with size 35.318 MiB + restored /restic + [...] + Summary: Restored 9072 files/dirs (153.597 MiB) in 0:00 + +Files with already up to date content are reported as ``unchanged``. Files whose content +was modified are ``updated`` and files that are new are shown as ``restored``. Directories +and other file types like symlinks are always reported as ``restored``. Restore using mount =================== diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst index d40f7c976..b83fe5eb5 100644 --- a/doc/075_scripting.rst +++ b/doc/075_scripting.rst @@ -511,6 +511,22 @@ Status |``bytes_skipped`` | Total size of skipped files | +----------------------+------------------------------------------------------------+ +Verbose Status +^^^^^^^^^^^^^^ + +Verbose status provides details about the progress, including details about restored files. +Only printed if `--verbose=2` is specified. + ++----------------------+-----------------------------------------------------------+ +| ``message_type`` | Always "verbose_status" | ++----------------------+-----------------------------------------------------------+ +| ``action`` | Either "restored", "updated" or "unchanged" | ++----------------------+-----------------------------------------------------------+ +| ``item`` | The item in question | ++----------------------+-----------------------------------------------------------+ +| ``size`` | Size of the item in bytes | ++----------------------+-----------------------------------------------------------+ + Summary ^^^^^^^ diff --git a/internal/restorer/filerestorer.go b/internal/restorer/filerestorer.go index f57d58598..fd5b3c5db 100644 --- a/internal/restorer/filerestorer.go +++ b/internal/restorer/filerestorer.go @@ -215,7 +215,7 @@ func (r *fileRestorer) restoreEmptyFileAt(location string) error { return err } - r.progress.AddProgress(location, 0, 0) + r.progress.AddProgress(location, restore.ActionFileRestored, 0, 0) return nil } @@ -337,7 +337,11 @@ func (r *fileRestorer) downloadBlobs(ctx context.Context, packID restic.ID, createSize = file.size } writeErr := r.filesWriter.writeToFile(r.targetPath(file.location), blobData, offset, createSize, file.sparse) - r.progress.AddProgress(file.location, uint64(len(blobData)), uint64(file.size)) + action := restore.ActionFileUpdated + if file.state == nil { + action = restore.ActionFileRestored + } + r.progress.AddProgress(file.location, action, uint64(len(blobData)), uint64(file.size)) return writeErr } err := r.sanitizeError(file, writeToFile()) diff --git a/internal/restorer/restorer.go b/internal/restorer/restorer.go index a80714fa7..650ad0731 100644 --- a/internal/restorer/restorer.go +++ b/internal/restorer/restorer.go @@ -33,6 +33,7 @@ type Restorer struct { var restorerAbortOnAllErrors = func(_ string, err error) error { return err } type Options struct { + DryRun bool Sparse bool Progress *restoreui.Progress Overwrite OverwriteBehavior @@ -220,22 +221,27 @@ func (res *Restorer) traverseTree(ctx context.Context, target, location string, } func (res *Restorer) restoreNodeTo(ctx context.Context, node *restic.Node, target, location string) error { - debug.Log("restoreNode %v %v %v", node.Name, target, location) - if err := fs.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Wrap(err, "RemoveNode") + if !res.opts.DryRun { + debug.Log("restoreNode %v %v %v", node.Name, target, location) + if err := fs.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Wrap(err, "RemoveNode") + } + + err := node.CreateAt(ctx, target, res.repo) + if err != nil { + debug.Log("node.CreateAt(%s) error %v", target, err) + return err + } } - err := node.CreateAt(ctx, target, res.repo) - if err != nil { - debug.Log("node.CreateAt(%s) error %v", target, err) - return err - } - - res.opts.Progress.AddProgress(location, 0, 0) + res.opts.Progress.AddProgress(location, restoreui.ActionFileRestored, 0, 0) return res.restoreNodeMetadataTo(node, target, location) } func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location string) error { + if res.opts.DryRun { + return nil + } debug.Log("restoreNodeMetadata %v %v %v", node.Name, target, location) err := node.RestoreMetadata(target, res.Warn) if err != nil { @@ -245,21 +251,26 @@ func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location s } func (res *Restorer) restoreHardlinkAt(node *restic.Node, target, path, location string) error { - if err := fs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Wrap(err, "RemoveCreateHardlink") - } - err := fs.Link(target, path) - if err != nil { - return errors.WithStack(err) + if !res.opts.DryRun { + if err := fs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Wrap(err, "RemoveCreateHardlink") + } + err := fs.Link(target, path) + if err != nil { + return errors.WithStack(err) + } } - res.opts.Progress.AddProgress(location, 0, 0) - + res.opts.Progress.AddProgress(location, restoreui.ActionFileRestored, 0, 0) // TODO investigate if hardlinks have separate metadata on any supported system return res.restoreNodeMetadataTo(node, path, location) } func (res *Restorer) ensureDir(target string) error { + if res.opts.DryRun { + return nil + } + fi, err := fs.Lstat(target) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to check for directory: %w", err) @@ -324,12 +335,21 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error { idx.Add(node.Inode, node.DeviceID, location) } - buf, err = res.withOverwriteCheck(node, target, false, buf, func(updateMetadataOnly bool, matches *fileState) error { + buf, err = res.withOverwriteCheck(node, target, location, false, buf, func(updateMetadataOnly bool, matches *fileState) error { if updateMetadataOnly { - res.opts.Progress.AddSkippedFile(node.Size) + res.opts.Progress.AddSkippedFile(location, node.Size) } else { res.opts.Progress.AddFile(node.Size) - filerestorer.addFile(location, node.Content, int64(node.Size), matches) + if !res.opts.DryRun { + filerestorer.addFile(location, node.Content, int64(node.Size), matches) + } else { + action := restoreui.ActionFileUpdated + if matches == nil { + action = restoreui.ActionFileRestored + } + // immediately mark as completed + res.opts.Progress.AddProgress(location, action, node.Size, node.Size) + } } res.trackFile(location, updateMetadataOnly) return nil @@ -341,9 +361,11 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error { return err } - err = filerestorer.restoreFiles(ctx) - if err != nil { - return err + if !res.opts.DryRun { + err = filerestorer.restoreFiles(ctx) + if err != nil { + return err + } } debug.Log("second pass for %q", dst) @@ -353,14 +375,14 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error { visitNode: func(node *restic.Node, target, location string) error { debug.Log("second pass, visitNode: restore node %q", location) if node.Type != "file" { - _, err := res.withOverwriteCheck(node, target, false, nil, func(_ bool, _ *fileState) error { + _, err := res.withOverwriteCheck(node, target, location, false, nil, func(_ bool, _ *fileState) error { return res.restoreNodeTo(ctx, node, target, location) }) return err } if idx.Has(node.Inode, node.DeviceID) && idx.Value(node.Inode, node.DeviceID) != location { - _, err := res.withOverwriteCheck(node, target, true, nil, func(_ bool, _ *fileState) error { + _, err := res.withOverwriteCheck(node, target, location, true, nil, func(_ bool, _ *fileState) error { return res.restoreHardlinkAt(node, filerestorer.targetPath(idx.Value(node.Inode, node.DeviceID)), target, location) }) return err @@ -375,7 +397,7 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error { leaveDir: func(node *restic.Node, target, location string) error { err := res.restoreNodeMetadataTo(node, target, location) if err == nil { - res.opts.Progress.AddProgress(location, 0, 0) + res.opts.Progress.AddProgress(location, restoreui.ActionDirRestored, 0, 0) } return err }, @@ -392,7 +414,7 @@ func (res *Restorer) hasRestoredFile(location string) (metadataOnly bool, ok boo return metadataOnly, ok } -func (res *Restorer) withOverwriteCheck(node *restic.Node, target string, isHardlink bool, buf []byte, cb func(updateMetadataOnly bool, matches *fileState) error) ([]byte, error) { +func (res *Restorer) withOverwriteCheck(node *restic.Node, target, location string, isHardlink bool, buf []byte, cb func(updateMetadataOnly bool, matches *fileState) error) ([]byte, error) { overwrite, err := shouldOverwrite(res.opts.Overwrite, node, target) if err != nil { return buf, err @@ -401,7 +423,7 @@ func (res *Restorer) withOverwriteCheck(node *restic.Node, target string, isHard if isHardlink { size = 0 } - res.opts.Progress.AddSkippedFile(size) + res.opts.Progress.AddSkippedFile(location, size) return buf, nil } diff --git a/internal/restorer/restorer_test.go b/internal/restorer/restorer_test.go index d70f1f162..5eca779c6 100644 --- a/internal/restorer/restorer_test.go +++ b/internal/restorer/restorer_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/restic/restic/internal/archiver" + "github.com/restic/restic/internal/errors" "github.com/restic/restic/internal/fs" "github.com/restic/restic/internal/repository" "github.com/restic/restic/internal/restic" @@ -1166,3 +1167,32 @@ func TestRestoreIfChanged(t *testing.T) { } } } + +func TestRestoreDryRun(t *testing.T) { + snapshot := Snapshot{ + Nodes: map[string]Node{ + "foo": File{Data: "content: foo\n", Links: 2, Inode: 42}, + "foo2": File{Data: "content: foo\n", Links: 2, Inode: 42}, + "dirtest": Dir{ + Nodes: map[string]Node{ + "file": File{Data: "content: file\n"}, + }, + }, + "link": Symlink{Target: "foo"}, + }, + } + + repo := repository.TestRepository(t) + tempdir := filepath.Join(rtest.TempDir(t), "target") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sn, id := saveSnapshot(t, repo, snapshot, noopGetGenericAttributes) + t.Logf("snapshot saved as %v", id.Str()) + + res := NewRestorer(repo, sn, Options{DryRun: true}) + rtest.OK(t, res.RestoreTo(ctx, tempdir)) + + _, err := os.Stat(tempdir) + rtest.Assert(t, errors.Is(err, os.ErrNotExist), "expected no file to be created, got %v", err) +} diff --git a/internal/restorer/restorer_unix_test.go b/internal/restorer/restorer_unix_test.go index 59048e908..2ad28a0f6 100644 --- a/internal/restorer/restorer_unix_test.go +++ b/internal/restorer/restorer_unix_test.go @@ -76,11 +76,21 @@ type printerMock struct { func (p *printerMock) Update(_ restoreui.State, _ time.Duration) { } +func (p *printerMock) CompleteItem(action restoreui.ItemAction, item string, size uint64) { +} func (p *printerMock) Finish(s restoreui.State, _ time.Duration) { p.s = s } func TestRestorerProgressBar(t *testing.T) { + testRestorerProgressBar(t, false) +} + +func TestRestorerProgressBarDryRun(t *testing.T) { + testRestorerProgressBar(t, true) +} + +func testRestorerProgressBar(t *testing.T, dryRun bool) { repo := repository.TestRepository(t) sn, _ := saveSnapshot(t, repo, Snapshot{ @@ -97,7 +107,7 @@ func TestRestorerProgressBar(t *testing.T) { mock := &printerMock{} progress := restoreui.NewProgress(mock, 0) - res := NewRestorer(repo, sn, Options{Progress: progress}) + res := NewRestorer(repo, sn, Options{Progress: progress, DryRun: dryRun}) res.SelectFilter = func(item string, dstpath string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool) { return true, true } diff --git a/internal/ui/restore/json.go b/internal/ui/restore/json.go index 512640a7a..ebc217176 100644 --- a/internal/ui/restore/json.go +++ b/internal/ui/restore/json.go @@ -7,12 +7,14 @@ import ( ) type jsonPrinter struct { - terminal term + terminal term + verbosity uint } -func NewJSONProgress(terminal term) ProgressPrinter { +func NewJSONProgress(terminal term, verbosity uint) ProgressPrinter { return &jsonPrinter{ - terminal: terminal, + terminal: terminal, + verbosity: verbosity, } } @@ -39,6 +41,34 @@ func (t *jsonPrinter) Update(p State, duration time.Duration) { t.print(status) } +func (t *jsonPrinter) CompleteItem(messageType ItemAction, item string, size uint64) { + if t.verbosity < 3 { + return + } + + var action string + switch messageType { + case ActionDirRestored: + action = "restored" + case ActionFileRestored: + action = "restored" + case ActionFileUpdated: + action = "updated" + case ActionFileUnchanged: + action = "unchanged" + default: + panic("unknown message type") + } + + status := verboseUpdate{ + MessageType: "verbose_status", + Action: action, + Item: item, + Size: size, + } + t.print(status) +} + func (t *jsonPrinter) Finish(p State, duration time.Duration) { status := summaryOutput{ MessageType: "summary", @@ -65,6 +95,13 @@ type statusUpdate struct { BytesSkipped uint64 `json:"bytes_skipped,omitempty"` } +type verboseUpdate struct { + MessageType string `json:"message_type"` // "verbose_status" + Action string `json:"action"` + Item string `json:"item"` + Size uint64 `json:"size"` +} + type summaryOutput struct { MessageType string `json:"message_type"` // "summary" SecondsElapsed uint64 `json:"seconds_elapsed,omitempty"` diff --git a/internal/ui/restore/json_test.go b/internal/ui/restore/json_test.go index 37983f7d7..1a749b933 100644 --- a/internal/ui/restore/json_test.go +++ b/internal/ui/restore/json_test.go @@ -7,37 +7,55 @@ import ( "github.com/restic/restic/internal/test" ) -func TestJSONPrintUpdate(t *testing.T) { +func createJSONProgress() (*mockTerm, ProgressPrinter) { term := &mockTerm{} - printer := NewJSONProgress(term) + printer := NewJSONProgress(term, 3) + return term, printer +} + +func TestJSONPrintUpdate(t *testing.T) { + term, printer := createJSONProgress() printer.Update(State{3, 11, 0, 29, 47, 0}, 5*time.Second) test.Equals(t, []string{"{\"message_type\":\"status\",\"seconds_elapsed\":5,\"percent_done\":0.6170212765957447,\"total_files\":11,\"files_restored\":3,\"total_bytes\":47,\"bytes_restored\":29}\n"}, term.output) } func TestJSONPrintUpdateWithSkipped(t *testing.T) { - term := &mockTerm{} - printer := NewJSONProgress(term) + term, printer := createJSONProgress() printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second) test.Equals(t, []string{"{\"message_type\":\"status\",\"seconds_elapsed\":5,\"percent_done\":0.6170212765957447,\"total_files\":11,\"files_restored\":3,\"files_skipped\":2,\"total_bytes\":47,\"bytes_restored\":29,\"bytes_skipped\":59}\n"}, term.output) } func TestJSONPrintSummaryOnSuccess(t *testing.T) { - term := &mockTerm{} - printer := NewJSONProgress(term) + term, printer := createJSONProgress() printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second) test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":11,\"total_bytes\":47,\"bytes_restored\":47}\n"}, term.output) } func TestJSONPrintSummaryOnErrors(t *testing.T) { - term := &mockTerm{} - printer := NewJSONProgress(term) + term, printer := createJSONProgress() printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second) test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":3,\"total_bytes\":47,\"bytes_restored\":29}\n"}, term.output) } func TestJSONPrintSummaryOnSuccessWithSkipped(t *testing.T) { - term := &mockTerm{} - printer := NewJSONProgress(term) + term, printer := createJSONProgress() printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second) test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":11,\"files_skipped\":2,\"total_bytes\":47,\"bytes_restored\":47,\"bytes_skipped\":59}\n"}, term.output) } + +func TestJSONPrintCompleteItem(t *testing.T) { + for _, data := range []struct { + action ItemAction + size uint64 + expected string + }{ + {ActionDirRestored, 0, "{\"message_type\":\"verbose_status\",\"action\":\"restored\",\"item\":\"test\",\"size\":0}\n"}, + {ActionFileRestored, 123, "{\"message_type\":\"verbose_status\",\"action\":\"restored\",\"item\":\"test\",\"size\":123}\n"}, + {ActionFileUpdated, 123, "{\"message_type\":\"verbose_status\",\"action\":\"updated\",\"item\":\"test\",\"size\":123}\n"}, + {ActionFileUnchanged, 123, "{\"message_type\":\"verbose_status\",\"action\":\"unchanged\",\"item\":\"test\",\"size\":123}\n"}, + } { + term, printer := createJSONProgress() + printer.CompleteItem(data.action, "test", data.size) + test.Equals(t, []string{data.expected}, term.output) + } +} diff --git a/internal/ui/restore/progress.go b/internal/ui/restore/progress.go index 7e8bcfd25..04274b7ea 100644 --- a/internal/ui/restore/progress.go +++ b/internal/ui/restore/progress.go @@ -39,9 +39,20 @@ type term interface { type ProgressPrinter interface { Update(progress State, duration time.Duration) + CompleteItem(action ItemAction, item string, size uint64) Finish(progress State, duration time.Duration) } +type ItemAction string + +// Constants for the different CompleteItem actions. +const ( + ActionDirRestored ItemAction = "dir restored" + ActionFileRestored ItemAction = "file restored" + ActionFileUpdated ItemAction = "file updated" + ActionFileUnchanged ItemAction = "file unchanged" +) + func NewProgress(printer ProgressPrinter, interval time.Duration) *Progress { p := &Progress{ progressInfoMap: make(map[string]progressInfoEntry), @@ -77,7 +88,7 @@ func (p *Progress) AddFile(size uint64) { } // AddProgress accumulates the number of bytes written for a file -func (p *Progress) AddProgress(name string, bytesWrittenPortion uint64, bytesTotal uint64) { +func (p *Progress) AddProgress(name string, action ItemAction, bytesWrittenPortion uint64, bytesTotal uint64) { if p == nil { return } @@ -96,10 +107,12 @@ func (p *Progress) AddProgress(name string, bytesWrittenPortion uint64, bytesTot if entry.bytesWritten == entry.bytesTotal { delete(p.progressInfoMap, name) p.s.FilesFinished++ + + p.printer.CompleteItem(action, name, bytesTotal) } } -func (p *Progress) AddSkippedFile(size uint64) { +func (p *Progress) AddSkippedFile(name string, size uint64) { if p == nil { return } @@ -109,6 +122,8 @@ func (p *Progress) AddSkippedFile(size uint64) { p.s.FilesSkipped++ p.s.AllBytesSkipped += size + + p.printer.CompleteItem(ActionFileUnchanged, name, size) } func (p *Progress) Finish() { diff --git a/internal/ui/restore/progress_test.go b/internal/ui/restore/progress_test.go index 56f5f62ce..eda1b05c0 100644 --- a/internal/ui/restore/progress_test.go +++ b/internal/ui/restore/progress_test.go @@ -16,8 +16,16 @@ type printerTraceEntry struct { type printerTrace []printerTraceEntry +type itemTraceEntry struct { + action ItemAction + item string + size uint64 +} + +type itemTrace []itemTraceEntry type mockPrinter struct { trace printerTrace + items itemTrace } const mockFinishDuration = 42 * time.Second @@ -25,95 +33,109 @@ const mockFinishDuration = 42 * time.Second func (p *mockPrinter) Update(progress State, duration time.Duration) { p.trace = append(p.trace, printerTraceEntry{progress, duration, false}) } +func (p *mockPrinter) CompleteItem(action ItemAction, item string, size uint64) { + p.items = append(p.items, itemTraceEntry{action, item, size}) +} func (p *mockPrinter) Finish(progress State, _ time.Duration) { p.trace = append(p.trace, printerTraceEntry{progress, mockFinishDuration, true}) } -func testProgress(fn func(progress *Progress) bool) printerTrace { +func testProgress(fn func(progress *Progress) bool) (printerTrace, itemTrace) { printer := &mockPrinter{} progress := NewProgress(printer, 0) final := fn(progress) progress.update(0, final) trace := append(printerTrace{}, printer.trace...) + items := append(itemTrace{}, printer.items...) // cleanup to avoid goroutine leak, but copy trace first progress.Finish() - return trace + return trace, items } func TestNew(t *testing.T) { - result := testProgress(func(progress *Progress) bool { + result, items := testProgress(func(progress *Progress) bool { return false }) test.Equals(t, printerTrace{ printerTraceEntry{State{0, 0, 0, 0, 0, 0}, 0, false}, }, result) + test.Equals(t, itemTrace{}, items) } func TestAddFile(t *testing.T) { fileSize := uint64(100) - result := testProgress(func(progress *Progress) bool { + result, items := testProgress(func(progress *Progress) bool { progress.AddFile(fileSize) return false }) test.Equals(t, printerTrace{ printerTraceEntry{State{0, 1, 0, 0, fileSize, 0}, 0, false}, }, result) + test.Equals(t, itemTrace{}, items) } func TestFirstProgressOnAFile(t *testing.T) { expectedBytesWritten := uint64(5) expectedBytesTotal := uint64(100) - result := testProgress(func(progress *Progress) bool { + result, items := testProgress(func(progress *Progress) bool { progress.AddFile(expectedBytesTotal) - progress.AddProgress("test", expectedBytesWritten, expectedBytesTotal) + progress.AddProgress("test", ActionFileUpdated, expectedBytesWritten, expectedBytesTotal) return false }) test.Equals(t, printerTrace{ printerTraceEntry{State{0, 1, 0, expectedBytesWritten, expectedBytesTotal, 0}, 0, false}, }, result) + test.Equals(t, itemTrace{}, items) } func TestLastProgressOnAFile(t *testing.T) { fileSize := uint64(100) - result := testProgress(func(progress *Progress) bool { + result, items := testProgress(func(progress *Progress) bool { progress.AddFile(fileSize) - progress.AddProgress("test", 30, fileSize) - progress.AddProgress("test", 35, fileSize) - progress.AddProgress("test", 35, fileSize) + progress.AddProgress("test", ActionFileUpdated, 30, fileSize) + progress.AddProgress("test", ActionFileUpdated, 35, fileSize) + progress.AddProgress("test", ActionFileUpdated, 35, fileSize) return false }) test.Equals(t, printerTrace{ printerTraceEntry{State{1, 1, 0, fileSize, fileSize, 0}, 0, false}, }, result) + test.Equals(t, itemTrace{ + itemTraceEntry{action: ActionFileUpdated, item: "test", size: fileSize}, + }, items) } func TestLastProgressOnLastFile(t *testing.T) { fileSize := uint64(100) - result := testProgress(func(progress *Progress) bool { + result, items := testProgress(func(progress *Progress) bool { progress.AddFile(fileSize) progress.AddFile(50) - progress.AddProgress("test1", 50, 50) - progress.AddProgress("test2", 50, fileSize) - progress.AddProgress("test2", 50, fileSize) + progress.AddProgress("test1", ActionFileUpdated, 50, 50) + progress.AddProgress("test2", ActionFileUpdated, 50, fileSize) + progress.AddProgress("test2", ActionFileUpdated, 50, fileSize) return false }) test.Equals(t, printerTrace{ printerTraceEntry{State{2, 2, 0, 50 + fileSize, 50 + fileSize, 0}, 0, false}, }, result) + test.Equals(t, itemTrace{ + itemTraceEntry{action: ActionFileUpdated, item: "test1", size: 50}, + itemTraceEntry{action: ActionFileUpdated, item: "test2", size: fileSize}, + }, items) } func TestSummaryOnSuccess(t *testing.T) { fileSize := uint64(100) - result := testProgress(func(progress *Progress) bool { + result, _ := testProgress(func(progress *Progress) bool { progress.AddFile(fileSize) progress.AddFile(50) - progress.AddProgress("test1", 50, 50) - progress.AddProgress("test2", fileSize, fileSize) + progress.AddProgress("test1", ActionFileUpdated, 50, 50) + progress.AddProgress("test2", ActionFileUpdated, fileSize, fileSize) return true }) test.Equals(t, printerTrace{ @@ -124,11 +146,11 @@ func TestSummaryOnSuccess(t *testing.T) { func TestSummaryOnErrors(t *testing.T) { fileSize := uint64(100) - result := testProgress(func(progress *Progress) bool { + result, _ := testProgress(func(progress *Progress) bool { progress.AddFile(fileSize) progress.AddFile(50) - progress.AddProgress("test1", 50, 50) - progress.AddProgress("test2", fileSize/2, fileSize) + progress.AddProgress("test1", ActionFileUpdated, 50, 50) + progress.AddProgress("test2", ActionFileUpdated, fileSize/2, fileSize) return true }) test.Equals(t, printerTrace{ @@ -139,11 +161,30 @@ func TestSummaryOnErrors(t *testing.T) { func TestSkipFile(t *testing.T) { fileSize := uint64(100) - result := testProgress(func(progress *Progress) bool { - progress.AddSkippedFile(fileSize) + result, items := testProgress(func(progress *Progress) bool { + progress.AddSkippedFile("test", fileSize) return true }) test.Equals(t, printerTrace{ printerTraceEntry{State{0, 0, 1, 0, 0, fileSize}, mockFinishDuration, true}, }, result) + test.Equals(t, itemTrace{ + itemTraceEntry{ActionFileUnchanged, "test", fileSize}, + }, items) +} + +func TestProgressTypes(t *testing.T) { + fileSize := uint64(100) + + _, items := testProgress(func(progress *Progress) bool { + progress.AddFile(fileSize) + progress.AddFile(0) + progress.AddProgress("dir", ActionDirRestored, fileSize, fileSize) + progress.AddProgress("new", ActionFileRestored, 0, 0) + return true + }) + test.Equals(t, itemTrace{ + itemTraceEntry{ActionDirRestored, "dir", fileSize}, + itemTraceEntry{ActionFileRestored, "new", 0}, + }, items) } diff --git a/internal/ui/restore/text.go b/internal/ui/restore/text.go index 28a6eb965..77c2f2d15 100644 --- a/internal/ui/restore/text.go +++ b/internal/ui/restore/text.go @@ -8,12 +8,14 @@ import ( ) type textPrinter struct { - terminal term + terminal term + verbosity uint } -func NewTextProgress(terminal term) ProgressPrinter { +func NewTextProgress(terminal term, verbosity uint) ProgressPrinter { return &textPrinter{ - terminal: terminal, + terminal: terminal, + verbosity: verbosity, } } @@ -31,6 +33,32 @@ func (t *textPrinter) Update(p State, duration time.Duration) { t.terminal.SetStatus([]string{progress}) } +func (t *textPrinter) CompleteItem(messageType ItemAction, item string, size uint64) { + if t.verbosity < 3 { + return + } + + var action string + switch messageType { + case ActionDirRestored: + action = "restored" + case ActionFileRestored: + action = "restored" + case ActionFileUpdated: + action = "updated" + case ActionFileUnchanged: + action = "unchanged" + default: + panic("unknown message type") + } + + if messageType == ActionDirRestored { + t.terminal.Print(fmt.Sprintf("restored %v", item)) + } else { + t.terminal.Print(fmt.Sprintf("%-9v %v with size %v", action, item, ui.FormatBytes(size))) + } +} + func (t *textPrinter) Finish(p State, duration time.Duration) { t.terminal.SetStatus([]string{}) diff --git a/internal/ui/restore/text_test.go b/internal/ui/restore/text_test.go index 3b776a7df..c7d173422 100644 --- a/internal/ui/restore/text_test.go +++ b/internal/ui/restore/text_test.go @@ -19,37 +19,55 @@ func (m *mockTerm) SetStatus(lines []string) { m.output = append([]string{}, lines...) } -func TestPrintUpdate(t *testing.T) { +func createTextProgress() (*mockTerm, ProgressPrinter) { term := &mockTerm{} - printer := NewTextProgress(term) + printer := NewTextProgress(term, 3) + return term, printer +} + +func TestPrintUpdate(t *testing.T) { + term, printer := createTextProgress() printer.Update(State{3, 11, 0, 29, 47, 0}, 5*time.Second) test.Equals(t, []string{"[0:05] 61.70% 3 files/dirs 29 B, total 11 files/dirs 47 B"}, term.output) } func TestPrintUpdateWithSkipped(t *testing.T) { - term := &mockTerm{} - printer := NewTextProgress(term) + term, printer := createTextProgress() printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second) test.Equals(t, []string{"[0:05] 61.70% 3 files/dirs 29 B, total 11 files/dirs 47 B, skipped 2 files/dirs 59 B"}, term.output) } func TestPrintSummaryOnSuccess(t *testing.T) { - term := &mockTerm{} - printer := NewTextProgress(term) + term, printer := createTextProgress() printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second) test.Equals(t, []string{"Summary: Restored 11 files/dirs (47 B) in 0:05"}, term.output) } func TestPrintSummaryOnErrors(t *testing.T) { - term := &mockTerm{} - printer := NewTextProgress(term) + term, printer := createTextProgress() printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second) test.Equals(t, []string{"Summary: Restored 3 / 11 files/dirs (29 B / 47 B) in 0:05"}, term.output) } func TestPrintSummaryOnSuccessWithSkipped(t *testing.T) { - term := &mockTerm{} - printer := NewTextProgress(term) + term, printer := createTextProgress() printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second) test.Equals(t, []string{"Summary: Restored 11 files/dirs (47 B) in 0:05, skipped 2 files/dirs 59 B"}, term.output) } + +func TestPrintCompleteItem(t *testing.T) { + for _, data := range []struct { + action ItemAction + size uint64 + expected string + }{ + {ActionDirRestored, 0, "restored test"}, + {ActionFileRestored, 123, "restored test with size 123 B"}, + {ActionFileUpdated, 123, "updated test with size 123 B"}, + {ActionFileUnchanged, 123, "unchanged test with size 123 B"}, + } { + term, printer := createTextProgress() + printer.CompleteItem(data.action, "test", data.size) + test.Equals(t, []string{data.expected}, term.output) + } +}