mirror of
https://github.com/restic/restic.git
synced 2026-02-17 06:23:56 +00:00
The previous implementation stored the whole tree in a map and used it for checking overlap between trees. This is now replaced with the DualTreeIterator, which iterates over two trees in parallel and returns the merge stream in order. In case of overlap between both trees, it returns both nodes at the same time. Otherwise, only a single node is returned.
470 lines
12 KiB
Go
470 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"path"
|
|
"reflect"
|
|
|
|
"github.com/restic/restic/internal/data"
|
|
"github.com/restic/restic/internal/debug"
|
|
"github.com/restic/restic/internal/errors"
|
|
"github.com/restic/restic/internal/global"
|
|
"github.com/restic/restic/internal/restic"
|
|
"github.com/restic/restic/internal/ui"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
func newDiffCommand(globalOptions *global.Options) *cobra.Command {
|
|
var opts DiffOptions
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "diff [flags] snapshotID snapshotID",
|
|
Short: "Show differences between two snapshots",
|
|
Long: `
|
|
The "diff" command shows differences from the first to the second snapshot. The
|
|
first characters in each line display what has happened to a particular file or
|
|
directory:
|
|
|
|
* + The item was added
|
|
* - The item was removed
|
|
* U The metadata (access mode, timestamps, ...) for the item was updated
|
|
* M The file's content was modified
|
|
* T The type was changed, e.g. a file was made a symlink
|
|
* ? Bitrot detected: The file's content has changed but all metadata is the same
|
|
|
|
Metadata comparison will likely not work if a backup was created using the
|
|
'--ignore-inode' or '--ignore-ctime' option.
|
|
|
|
To only compare files in specific subfolders, you can use the
|
|
"snapshotID:subfolder" syntax, where "subfolder" is a path within the
|
|
snapshot.
|
|
|
|
EXIT STATUS
|
|
===========
|
|
|
|
Exit status is 0 if the command was successful.
|
|
Exit status is 1 if there was any error.
|
|
Exit status is 10 if the repository does not exist.
|
|
Exit status is 11 if the repository is already locked.
|
|
Exit status is 12 if the password is incorrect.
|
|
`,
|
|
GroupID: cmdGroupDefault,
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runDiff(cmd.Context(), opts, *globalOptions, args, globalOptions.Term)
|
|
},
|
|
}
|
|
|
|
opts.AddFlags(cmd.Flags())
|
|
return cmd
|
|
}
|
|
|
|
// DiffOptions collects all options for the diff command.
|
|
type DiffOptions struct {
|
|
ShowMetadata bool
|
|
}
|
|
|
|
func (opts *DiffOptions) AddFlags(f *pflag.FlagSet) {
|
|
f.BoolVar(&opts.ShowMetadata, "metadata", false, "print changes in metadata")
|
|
}
|
|
|
|
func loadSnapshot(ctx context.Context, be restic.Lister, repo restic.LoaderUnpacked, desc string) (*data.Snapshot, string, error) {
|
|
sn, subfolder, err := data.FindSnapshot(ctx, be, repo, desc)
|
|
if err != nil {
|
|
return nil, "", errors.Fatalf("%s", err)
|
|
}
|
|
return sn, subfolder, err
|
|
}
|
|
|
|
// Comparer collects all things needed to compare two snapshots.
|
|
type Comparer struct {
|
|
repo restic.BlobLoader
|
|
opts DiffOptions
|
|
printChange func(change *Change)
|
|
printError func(string, ...interface{})
|
|
}
|
|
|
|
type Change struct {
|
|
MessageType string `json:"message_type"` // "change"
|
|
Path string `json:"path"`
|
|
Modifier string `json:"modifier"`
|
|
}
|
|
|
|
func NewChange(path string, mode string) *Change {
|
|
return &Change{MessageType: "change", Path: path, Modifier: mode}
|
|
}
|
|
|
|
// DiffStat collects stats for all types of items.
|
|
type DiffStat struct {
|
|
Files int `json:"files"`
|
|
Dirs int `json:"dirs"`
|
|
Others int `json:"others"`
|
|
DataBlobs int `json:"data_blobs"`
|
|
TreeBlobs int `json:"tree_blobs"`
|
|
Bytes uint64 `json:"bytes"`
|
|
}
|
|
|
|
// Add adds stats information for node to s.
|
|
func (s *DiffStat) Add(node *data.Node) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
|
|
switch node.Type {
|
|
case data.NodeTypeFile:
|
|
s.Files++
|
|
case data.NodeTypeDir:
|
|
s.Dirs++
|
|
default:
|
|
s.Others++
|
|
}
|
|
}
|
|
|
|
// addBlobs adds the blobs of node to s.
|
|
func addBlobs(bs restic.AssociatedBlobSet, node *data.Node) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
|
|
switch node.Type {
|
|
case data.NodeTypeFile:
|
|
for _, blob := range node.Content {
|
|
h := restic.BlobHandle{
|
|
ID: blob,
|
|
Type: restic.DataBlob,
|
|
}
|
|
bs.Insert(h)
|
|
}
|
|
case data.NodeTypeDir:
|
|
h := restic.BlobHandle{
|
|
ID: *node.Subtree,
|
|
Type: restic.TreeBlob,
|
|
}
|
|
bs.Insert(h)
|
|
}
|
|
}
|
|
|
|
type DiffStatsContainer struct {
|
|
MessageType string `json:"message_type"` // "statistics"
|
|
SourceSnapshot string `json:"source_snapshot"`
|
|
TargetSnapshot string `json:"target_snapshot"`
|
|
ChangedFiles int `json:"changed_files"`
|
|
Added DiffStat `json:"added"`
|
|
Removed DiffStat `json:"removed"`
|
|
BlobsBefore, BlobsAfter, BlobsCommon restic.AssociatedBlobSet `json:"-"`
|
|
}
|
|
|
|
// updateBlobs updates the blob counters in the stats struct.
|
|
func updateBlobs(repo restic.Loader, blobs restic.AssociatedBlobSet, stats *DiffStat, printError func(string, ...interface{})) {
|
|
for h := range blobs.Keys() {
|
|
switch h.Type {
|
|
case restic.DataBlob:
|
|
stats.DataBlobs++
|
|
case restic.TreeBlob:
|
|
stats.TreeBlobs++
|
|
}
|
|
|
|
size, found := repo.LookupBlobSize(h.Type, h.ID)
|
|
if !found {
|
|
printError("unable to find blob size for %v", h)
|
|
continue
|
|
}
|
|
|
|
stats.Bytes += uint64(size)
|
|
}
|
|
}
|
|
|
|
func (c *Comparer) printDir(ctx context.Context, mode string, stats *DiffStat, blobs restic.AssociatedBlobSet, prefix string, id restic.ID) error {
|
|
debug.Log("print %v tree %v", mode, id)
|
|
tree, err := data.LoadTree(ctx, c.repo, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for item := range tree {
|
|
if item.Error != nil {
|
|
return item.Error
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
node := item.Node
|
|
name := path.Join(prefix, node.Name)
|
|
if node.Type == data.NodeTypeDir {
|
|
name += "/"
|
|
}
|
|
c.printChange(NewChange(name, mode))
|
|
stats.Add(node)
|
|
addBlobs(blobs, node)
|
|
|
|
if node.Type == data.NodeTypeDir {
|
|
err := c.printDir(ctx, mode, stats, blobs, name, *node.Subtree)
|
|
if err != nil && err != context.Canceled {
|
|
c.printError("error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctx.Err()
|
|
}
|
|
|
|
func (c *Comparer) collectDir(ctx context.Context, blobs restic.AssociatedBlobSet, id restic.ID) error {
|
|
debug.Log("print tree %v", id)
|
|
tree, err := data.LoadTree(ctx, c.repo, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for item := range tree {
|
|
if item.Error != nil {
|
|
return item.Error
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
node := item.Node
|
|
addBlobs(blobs, node)
|
|
|
|
if node.Type == data.NodeTypeDir {
|
|
err := c.collectDir(ctx, blobs, *node.Subtree)
|
|
if err != nil && err != context.Canceled {
|
|
c.printError("error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctx.Err()
|
|
}
|
|
|
|
func (c *Comparer) diffTree(ctx context.Context, stats *DiffStatsContainer, prefix string, id1, id2 restic.ID) error {
|
|
debug.Log("diffing %v to %v", id1, id2)
|
|
tree1, err := data.LoadTree(ctx, c.repo, id1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tree2, err := data.LoadTree(ctx, c.repo, id2)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for dt := range data.DualTreeIterator(tree1, tree2) {
|
|
if dt.Error != nil {
|
|
return dt.Error
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
node1 := dt.Tree1
|
|
node2 := dt.Tree2
|
|
|
|
var name string
|
|
if node1 != nil {
|
|
name = node1.Name
|
|
} else {
|
|
name = node2.Name
|
|
}
|
|
|
|
addBlobs(stats.BlobsBefore, node1)
|
|
addBlobs(stats.BlobsAfter, node2)
|
|
|
|
switch {
|
|
case node1 != nil && node2 != nil:
|
|
name := path.Join(prefix, name)
|
|
mod := ""
|
|
|
|
if node1.Type != node2.Type {
|
|
mod += "T"
|
|
}
|
|
|
|
if node2.Type == data.NodeTypeDir {
|
|
name += "/"
|
|
}
|
|
|
|
if node1.Type == data.NodeTypeFile &&
|
|
node2.Type == data.NodeTypeFile &&
|
|
!reflect.DeepEqual(node1.Content, node2.Content) {
|
|
mod += "M"
|
|
stats.ChangedFiles++
|
|
|
|
node1NilContent := *node1
|
|
node2NilContent := *node2
|
|
node1NilContent.Content = nil
|
|
node2NilContent.Content = nil
|
|
// the bitrot detection may not work if `backup --ignore-inode` or `--ignore-ctime` were used
|
|
if node1NilContent.Equals(node2NilContent) {
|
|
// probable bitrot detected
|
|
mod += "?"
|
|
}
|
|
} else if c.opts.ShowMetadata && !node1.Equals(*node2) {
|
|
mod += "U"
|
|
}
|
|
|
|
if mod != "" {
|
|
c.printChange(NewChange(name, mod))
|
|
}
|
|
|
|
if node1.Type == data.NodeTypeDir && node2.Type == data.NodeTypeDir {
|
|
var err error
|
|
if (*node1.Subtree).Equal(*node2.Subtree) {
|
|
err = c.collectDir(ctx, stats.BlobsCommon, *node1.Subtree)
|
|
} else {
|
|
err = c.diffTree(ctx, stats, name, *node1.Subtree, *node2.Subtree)
|
|
}
|
|
if err != nil && err != context.Canceled {
|
|
c.printError("error: %v", err)
|
|
}
|
|
}
|
|
case node1 != nil && node2 == nil:
|
|
prefix := path.Join(prefix, name)
|
|
if node1.Type == data.NodeTypeDir {
|
|
prefix += "/"
|
|
}
|
|
c.printChange(NewChange(prefix, "-"))
|
|
stats.Removed.Add(node1)
|
|
|
|
if node1.Type == data.NodeTypeDir {
|
|
err := c.printDir(ctx, "-", &stats.Removed, stats.BlobsBefore, prefix, *node1.Subtree)
|
|
if err != nil && err != context.Canceled {
|
|
c.printError("error: %v", err)
|
|
}
|
|
}
|
|
case node1 == nil && node2 != nil:
|
|
prefix := path.Join(prefix, name)
|
|
if node2.Type == data.NodeTypeDir {
|
|
prefix += "/"
|
|
}
|
|
c.printChange(NewChange(prefix, "+"))
|
|
stats.Added.Add(node2)
|
|
|
|
if node2.Type == data.NodeTypeDir {
|
|
err := c.printDir(ctx, "+", &stats.Added, stats.BlobsAfter, prefix, *node2.Subtree)
|
|
if err != nil && err != context.Canceled {
|
|
c.printError("error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctx.Err()
|
|
}
|
|
|
|
func runDiff(ctx context.Context, opts DiffOptions, gopts global.Options, args []string, term ui.Terminal) error {
|
|
if len(args) != 2 {
|
|
return errors.Fatalf("specify two snapshot IDs")
|
|
}
|
|
|
|
printer := ui.NewProgressPrinter(gopts.JSON, gopts.Verbosity, term)
|
|
|
|
ctx, repo, unlock, err := openWithReadLock(ctx, gopts, gopts.NoLock, printer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
|
|
// cache snapshots listing
|
|
be, err := restic.MemorizeList(ctx, repo, restic.SnapshotFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sn1, subfolder1, err := loadSnapshot(ctx, be, repo, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sn2, subfolder2, err := loadSnapshot(ctx, be, repo, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !gopts.JSON {
|
|
printer.P("comparing snapshot %v to %v:\n\n", sn1.ID().Str(), sn2.ID().Str())
|
|
}
|
|
if err = repo.LoadIndex(ctx, printer); err != nil {
|
|
return err
|
|
}
|
|
|
|
if sn1.Tree == nil {
|
|
return errors.Errorf("snapshot %v has nil tree", sn1.ID().Str())
|
|
}
|
|
|
|
if sn2.Tree == nil {
|
|
return errors.Errorf("snapshot %v has nil tree", sn2.ID().Str())
|
|
}
|
|
|
|
sn1.Tree, err = data.FindTreeDirectory(ctx, repo, sn1.Tree, subfolder1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sn2.Tree, err = data.FindTreeDirectory(ctx, repo, sn2.Tree, subfolder2)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c := &Comparer{
|
|
repo: repo,
|
|
opts: opts,
|
|
printError: printer.E,
|
|
printChange: func(change *Change) {
|
|
printer.S("%-5s%v", change.Modifier, change.Path)
|
|
},
|
|
}
|
|
|
|
if gopts.JSON {
|
|
enc := json.NewEncoder(gopts.Term.OutputWriter())
|
|
c.printChange = func(change *Change) {
|
|
err := enc.Encode(change)
|
|
if err != nil {
|
|
printer.E("JSON encode failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if gopts.Quiet {
|
|
c.printChange = func(_ *Change) {}
|
|
}
|
|
|
|
stats := &DiffStatsContainer{
|
|
MessageType: "statistics",
|
|
SourceSnapshot: args[0],
|
|
TargetSnapshot: args[1],
|
|
BlobsBefore: repo.NewAssociatedBlobSet(),
|
|
BlobsAfter: repo.NewAssociatedBlobSet(),
|
|
BlobsCommon: repo.NewAssociatedBlobSet(),
|
|
}
|
|
stats.BlobsBefore.Insert(restic.BlobHandle{Type: restic.TreeBlob, ID: *sn1.Tree})
|
|
stats.BlobsAfter.Insert(restic.BlobHandle{Type: restic.TreeBlob, ID: *sn2.Tree})
|
|
|
|
err = c.diffTree(ctx, stats, "/", *sn1.Tree, *sn2.Tree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
both := stats.BlobsBefore.Intersect(stats.BlobsAfter)
|
|
updateBlobs(repo, stats.BlobsBefore.Sub(both).Sub(stats.BlobsCommon), &stats.Removed, printer.E)
|
|
updateBlobs(repo, stats.BlobsAfter.Sub(both).Sub(stats.BlobsCommon), &stats.Added, printer.E)
|
|
|
|
if gopts.JSON {
|
|
err := json.NewEncoder(gopts.Term.OutputWriter()).Encode(stats)
|
|
if err != nil {
|
|
printer.E("JSON encode failed: %v", err)
|
|
}
|
|
} else {
|
|
printer.S("")
|
|
printer.S("Files: %5d new, %5d removed, %5d changed", stats.Added.Files, stats.Removed.Files, stats.ChangedFiles)
|
|
printer.S("Dirs: %5d new, %5d removed", stats.Added.Dirs, stats.Removed.Dirs)
|
|
printer.S("Others: %5d new, %5d removed", stats.Added.Others, stats.Removed.Others)
|
|
printer.S("Data Blobs: %5d new, %5d removed", stats.Added.DataBlobs, stats.Removed.DataBlobs)
|
|
printer.S("Tree Blobs: %5d new, %5d removed", stats.Added.TreeBlobs, stats.Removed.TreeBlobs)
|
|
printer.S(" Added: %-5s", ui.FormatBytes(stats.Added.Bytes))
|
|
printer.S(" Removed: %-5s", ui.FormatBytes(stats.Removed.Bytes))
|
|
}
|
|
|
|
return nil
|
|
}
|