Merge pull request #4839 from MichaelEischer/restore-dry-run

restore: add `--dry-run` and extended progress output
This commit is contained in:
Michael Eischer 2024-07-05 21:02:13 +02:00 committed by GitHub
commit 83fdcf21fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 360 additions and 86 deletions

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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
===================

View file

@ -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
^^^^^^^

View file

@ -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())

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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"`

View file

@ -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)
}
}

View file

@ -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() {

View file

@ -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)
}

View file

@ -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{})

View file

@ -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)
}
}